From 569d4c45d72994f3372c4f5de897f32e7c252f51 Mon Sep 17 00:00:00 2001 From: Chinenye Nmoh Date: Tue, 17 Mar 2026 09:37:08 +0100 Subject: [PATCH 1/2] added extra query --- .env.local.example | 30 +- .env.remote.example | 30 +- .gitignore | 10 +- Dockerfile | 42 +- README.md | 150 +-- app.py | 14 +- app/__init__.py | 72 +- app/config.py | 166 +-- app/enums/__init__.py | 6 +- app/enums/loan_status.py | 14 +- app/enums/repayment_schedule_status.py | 12 +- app/enums/transaction_type.py | 20 +- app/extensions.py | 8 +- app/helpers/collect_loan_helper.py | 124 +- app/helpers/response_helper.py | 500 ++++----- app/integrations/__init__.py | 4 +- app/integrations/bank_service.py | 48 +- app/integrations/kafka.py | 312 +++--- app/integrations/simbrella.py | 1090 +++++++++--------- app/models/__init__.py | 18 +- app/models/account.py | 50 +- app/models/customer.py | 82 +- app/models/loan.py | 830 +++++++------- app/models/loan_charge.py | 252 ++--- app/models/loan_repayment_schedule.py | 681 +++++------ app/models/repayment.py | 518 ++++----- app/models/repayments_data.py | 148 +-- app/models/salary.py | 196 ++-- app/models/transactions.py | 134 +-- app/response/__init__.py | 2 +- app/response/handlers.py | 60 +- app/routes/__init__.py | 4 +- app/routes/authentication.py | 274 ++--- app/routes/autocall.py | 1368 +++++++++++------------ app/services/loan.py | 294 ++--- app/services/loan_charge.py | 24 +- app/services/loan_repayment_schedule.py | 272 ++--- app/services/repayment.py | 154 +-- app/services/repayments_data.py | 20 +- app/services/salary.py | 46 +- app/services/transactions.py | 34 +- app/utils/auth.py | 90 +- app/utils/extras.py | 30 +- app/utils/logger.py | 26 +- app/utils/mail.py | 78 +- docker-compose.yml | 64 +- openapi.yml | 516 ++++----- requirements.txt | 32 +- wsgi.py | 72 +- 49 files changed, 4516 insertions(+), 4505 deletions(-) diff --git a/.env.local.example b/.env.local.example index aee093d..181b4d1 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,16 +1,16 @@ - -KAFKA_TIMEOUT=1000.0 -KAFKA_BROKER="10.20.30.50:9092" -KAFKA_TOPICS=PROCESS_PAYMENT,LOAN_REPAYMENT - -# DATABASE_USER=firstadvance -# DATABASE_PASSWORD=FirstAdvance! -# DATABASE_HOST=10.20.30.60 -# DATABASE_PORT=5432 -# DATABASE_NAME=firstadvancedev - -DATABASE_USER=system -DATABASE_PASSWORD=FIRSTADV_PASS -DATABASE_HOST=10.10.33.65 -DATABASE_PORT=1521 + +KAFKA_TIMEOUT=1000.0 +KAFKA_BROKER="10.20.30.50:9092" +KAFKA_TOPICS=PROCESS_PAYMENT,LOAN_REPAYMENT + +# DATABASE_USER=firstadvance +# DATABASE_PASSWORD=FirstAdvance! +# DATABASE_HOST=10.20.30.60 +# DATABASE_PORT=5432 +# DATABASE_NAME=firstadvancedev + +DATABASE_USER=system +DATABASE_PASSWORD=FIRSTADV_PASS +DATABASE_HOST=10.10.33.65 +DATABASE_PORT=1521 DATABASE_SID=FREE \ No newline at end of file diff --git a/.env.remote.example b/.env.remote.example index 642890d..6ff4ef1 100644 --- a/.env.remote.example +++ b/.env.remote.example @@ -1,16 +1,16 @@ - -KAFKA_TIMEOUT=1000.0 -KAFKA_BROKER="dev-events.simbrellang.net:9085" -KAFKA_TOPICS=PROCESS_PAYMENT,LOAN_REPAYMENT - -# DATABASE_USER=firstadvance -# DATABASE_PASSWORD=FirstAdvance! -# DATABASE_HOST=dev-data.simbrellang.net -# DATABASE_PORT=10532 -# DATABASE_NAME=firstadvancedev - -DATABASE_USER=system -DATABASE_PASSWORD=FIRSTADV_PASS -DATABASE_HOST=10.10.33.65 -DATABASE_PORT=1521 + +KAFKA_TIMEOUT=1000.0 +KAFKA_BROKER="dev-events.simbrellang.net:9085" +KAFKA_TOPICS=PROCESS_PAYMENT,LOAN_REPAYMENT + +# DATABASE_USER=firstadvance +# DATABASE_PASSWORD=FirstAdvance! +# DATABASE_HOST=dev-data.simbrellang.net +# DATABASE_PORT=10532 +# DATABASE_NAME=firstadvancedev + +DATABASE_USER=system +DATABASE_PASSWORD=FIRSTADV_PASS +DATABASE_HOST=10.10.33.65 +DATABASE_PORT=1521 DATABASE_SID=FREE \ No newline at end of file diff --git a/.gitignore b/.gitignore index f5ccd79..bb3f7b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ -.vscode/ -__pycache__/ -*/__pycache__/ -.env -app.log +.vscode/ +__pycache__/ +*/__pycache__/ +.env +app.log .idea/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index bdb26bc..a1b0d86 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,21 @@ -# Use Python base image -FROM python:3.10 - -# Set working directory -WORKDIR /app - -# Copy files to container -COPY . /app - -# Install dependencies -RUN pip install --no-cache-dir -r requirements.txt - -# Expose port 5000 -EXPOSE 5000 - -# Set environment variables -ENV FLASK_APP=app.py -ENV FLASK_RUN_HOST=0.0.0.0 - -# Run the application -CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "--timeout", "120", "wsgi:wsgi_app"] +# Use Python base image +FROM python:3.10 + +# Set working directory +WORKDIR /app + +# Copy files to container +COPY . /app + +# Install dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Expose port 5000 +EXPOSE 5000 + +# Set environment variables +ENV FLASK_APP=app.py +ENV FLASK_RUN_HOST=0.0.0.0 + +# Run the application +CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "--timeout", "120", "wsgi:wsgi_app"] diff --git a/README.md b/README.md index 096cabb..bdd65da 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,75 @@ -# Flask API with Swagger in Docker - -This repository contains a Flask API with Swagger documentation, running inside Docker containers. - -## Getting Started - -### Prerequisites - -Ensure you have the following installed: - -- [Docker](https://www.docker.com/get-started) -- [Docker Compose](https://docs.docker.com/compose/install/) - -### Clone the Repository - -```sh -git clone -cd -``` - -### Setup and Run the Application - -#### 1. Build and Start the Containers - -```sh -docker-compose up --build -d -``` - -This will: - -- Build the Flask API container. -- Start the Swagger UI container separately. - -#### 2. Verify the Containers are Running - -```sh -docker ps -``` - -#### 3. Access the API - -- The **Flask API** will be available at: - ``` - http://localhost:5000 - ``` -- The **Swagger UI** will be available at: - ``` - http://localhost:9000 - ``` - You can interact with the API documentation and test endpoints from here. - -## Making API Requests - -You can send requests using tools like `curl` or Postman: - -```sh -curl -X GET http://localhost:5000/hello -``` - -## Stopping the Application - -To stop the running containers, use: - -```sh -docker-compose down -``` - -## Troubleshooting - -- If there are permission issues, try running Docker commands with `sudo`. -- If ports are in use, change them in `docker-compose.yml`. -- To rebuild the containers, use: - ```sh - docker-compose up --build -d - ``` +# Flask API with Swagger in Docker + +This repository contains a Flask API with Swagger documentation, running inside Docker containers. + +## Getting Started + +### Prerequisites + +Ensure you have the following installed: + +- [Docker](https://www.docker.com/get-started) +- [Docker Compose](https://docs.docker.com/compose/install/) + +### Clone the Repository + +```sh +git clone +cd +``` + +### Setup and Run the Application + +#### 1. Build and Start the Containers + +```sh +docker-compose up --build -d +``` + +This will: + +- Build the Flask API container. +- Start the Swagger UI container separately. + +#### 2. Verify the Containers are Running + +```sh +docker ps +``` + +#### 3. Access the API + +- The **Flask API** will be available at: + ``` + http://localhost:5000 + ``` +- The **Swagger UI** will be available at: + ``` + http://localhost:9000 + ``` + You can interact with the API documentation and test endpoints from here. + +## Making API Requests + +You can send requests using tools like `curl` or Postman: + +```sh +curl -X GET http://localhost:5000/hello +``` + +## Stopping the Application + +To stop the running containers, use: + +```sh +docker-compose down +``` + +## Troubleshooting + +- If there are permission issues, try running Docker commands with `sudo`. +- If ports are in use, change them in `docker-compose.yml`. +- To rebuild the containers, use: + ```sh + docker-compose up --build -d + ``` diff --git a/app.py b/app.py index 51efc32..4b953a5 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,7 @@ -from app import create_app - -app = create_app() - -if __name__ == "__main__": - - app.run(host="0.0.0.0", port=5000, debug=True) +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 index ed6ddc3..dff525a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,36 +1,36 @@ -from flask import Flask -from flask_mail import Mail -from flask_cors import CORS -from app.config import Config -from app.routes import auth_bp, autocall_bp -from app.response import (method_not_allowed, unsupported_media_type, not_found, bad_request) -from app.extensions import db, mail - - -def create_app(): - """Factory function to create a Flask app instance""" - app = Flask(__name__) - - # Load configuration - app.config.from_object(Config) - - # Setup CORS - CORS(app) - - # Initialize Flask-Mail - mail.init_app(app) - - # Register blueprints - app.register_blueprint(auth_bp) - app.register_blueprint(autocall_bp, url_prefix="/autocall") - - # Error Handlers - app.register_error_handler(405, method_not_allowed) - app.register_error_handler(415, unsupported_media_type) - app.register_error_handler(404, not_found) - app.register_error_handler(400, bad_request) - - # Database - db.init_app(app) - - return app +from flask import Flask +from flask_mail import Mail +from flask_cors import CORS +from app.config import Config +from app.routes import auth_bp, autocall_bp +from app.response import (method_not_allowed, unsupported_media_type, not_found, bad_request) +from app.extensions import db, mail + + +def create_app(): + """Factory function to create a Flask app instance""" + app = Flask(__name__) + + # Load configuration + app.config.from_object(Config) + + # Setup CORS + CORS(app) + + # Initialize Flask-Mail + mail.init_app(app) + + # Register blueprints + app.register_blueprint(auth_bp) + app.register_blueprint(autocall_bp, url_prefix="/autocall") + + # Error Handlers + app.register_error_handler(405, method_not_allowed) + app.register_error_handler(415, unsupported_media_type) + app.register_error_handler(404, not_found) + app.register_error_handler(400, bad_request) + + # Database + db.init_app(app) + + return app diff --git a/app/config.py b/app/config.py index dbc0fa8..9adf436 100644 --- a/app/config.py +++ b/app/config.py @@ -1,83 +1,83 @@ -import os -from datetime import timedelta - - -class Config: - """Base configuration for Flask app""" - - SECRET_KEY = os.getenv("SECRET_KEY", "supersecretkey") - JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your_jwt_secret") - DEBUG = True - - KAFKA_BROKER = os.getenv("KAFKA_BROKER", "dev-events.simbrellang.net:9085") - KAFKA_TOPICS = [topic.strip() for topic in os.getenv("KAFKA_TOPICS", "PROCESS_PAYMENT,LOAN_REPAYMENT").split(",") if topic.strip()] - KAFKA_TIMEOUT = float( os.getenv("KAFKA_TIMEOUT", 1000.0) ) - - 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) - ) - - BANK_CALL_APP_ID = os.getenv("BANK_CALL_APP_ID", "app1") - BANK_CALL_API_KEY = os.getenv("BANK_CALL_API_KEY", "testtest-api-key-12345") - BANK_CALL_BASIC_AUTH_USERNAME = os.environ.get( - "BANK_CALL_BASIC_AUTH_USERNAME", "simbrella" - ) - BANK_CALL_BASIC_AUTH_PASSWORD = os.environ.get( - "BANK_CALL_BASIC_AUTH_PASSWORD", "G7$k9@pL2!qR" - ) - - DATABASE_USER = os.getenv("DATABASE_USER") - DATABASE_PASSWORD = os.getenv("DATABASE_PASSWORD") - DATABASE_HOST = os.getenv("DATABASE_HOST") - DATABASE_NAME = os.getenv("DATABASE_NAME") - DATABASE_PORT = os.getenv("DATABASE_PORT", 10532) - DATABASE_SID = os.environ.get("DATABASE_SID", "FREE") - DNS = f"(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST={DATABASE_HOST})(PORT={DATABASE_PORT}))(CONNECT_DATA=(SID={DATABASE_SID})))" - - - SQLALCHEMY_DATABASE_URI_INTERNAL = (f"oracle+oracledb://{DATABASE_USER}:{DATABASE_PASSWORD}@{DNS}") - #SQLALCHEMY_DATABASE_URI_INTERNAL = (f"oracle+oracledb://{DATABASE_USER}:{DATABASE_PASSWORD}@{DNS}") - SQLALCHEMY_DATABASE_URI = os.getenv("SQLALCHEMY_DATABASE_URI_FULL", SQLALCHEMY_DATABASE_URI_INTERNAL) - - # SQLALCHEMY_DATABASE_URI = f"postgresql+psycopg2://{DATABASE_USER}:{DATABASE_PASSWORD}@{DATABASE_HOST}:{DATABASE_PORT}/{DATABASE_NAME}" - SQLALCHEMY_TRACK_MODIFICATIONS = False - # SQLALCHEMY_ECHO = True - OVERRIDE_COLLECTION_TRANCATION_ID = int(os.getenv("OVERRIDE_COLLECTION_TRANCATION_ID", 100)) - - - MAIL_SERVER = os.getenv('MAIL_SERVER','smtp.zoho.com') - MAIL_PORT = os.getenv('MAIL_PORT', 587) - MAIL_USERNAME = os.getenv('MAIL_USERNAME', 'firstadvance@dynamikservices.tech') - MAIL_PASSWORD = os.getenv('MAIL_PASSWORD') - MAIL_USE_TLS = os.getenv('MAIL_USE_TLS', 'True').lower() in ('true', '1', 'yes') - MAIL_USE_SSL = os.getenv('MAIL_USE_SSL', 'False').lower() in ('true', '1', 'yes') - MAIL_DEFAULT_SENDER = ('FirstAdvance', 'firstadvance@dynamikservices.tech') - MAIL_RECEIVER= os.getenv('MAIL_RECEIVER', 'chinenyeumeaku@gmail.com,umeakuchinenye@gmail.com') - - - # Processing Overdue LOANS sections - OVERDUE_LOAN_BATCH_SIZE = int(os.getenv("OVERDUE_LOAN_BATCH_SIZE", 10)) - OVERDUE_LOAN_DELAY_SECONDS = int(os.getenv("OVERDUE_LOAN_DELAY_SECONDS", 5)) - OVERDUE_LOAN_BATCH_DELAY_SECONDS = int( - os.getenv("OVERDUE_LOAN_BATCH_DELAY_SECONDS", 5) - ) - OVERDUE_GRACE_PERIOD_DAYS = int(os.getenv("OVERDUE_GRACE_PERIOD_DAYS", 30)) - OVERDUE_PROCESSING_LIST_LIMIT = int(os.getenv("OVERDUE_PROCESSING_LIST_LIMIT", 100)) - PENAL_CHARGE_PERCENTAGE = os.getenv("PENAL_CHARGE_PERCENTAGE", 1) - PENAL_CHARGE_INTERVAL_DAYS = os.getenv("PENAL_CHARGE_INTERVAL_DAYS", 30) - PENAL_CHARGE_MAXIMUM_COUNT = os.getenv("PENAL_CHARGE_MAXIMUM_COUNT", 6) - - - BANK_CALL_API_TIME_OUT = os.getenv("BANK_CALL_API_TIME_OUT", 100) - BANK_CALL_BASE_URL = os.getenv("BANK_CALL_BASE_URL", "https://bank-emulator.dev.simbrellang.net/api") - BANK_CALL_SMS_BASE_URL= os.getenv("BANK_CALL_SMS_BASE_URL","https://first-advance-middleware-develop.fbn-devops-dev-asenv.appserviceenvironment.net/SMS") - BANK_CALL_DISBURSE_LOAN_ENDPOINT = os.getenv("BANK_CALL_DISBURSE_LOAN_ENDPOINT","/DisburseLoan") - BANK_CALL_COLLECT_LOAN_ENDPOINT = os.getenv("BANK_CALL_COLLECT_LOAN_ENDPOINT","/CollectLoan") - BANK_CALL_TRANSACTION_VERIFY = os.getenv("BANK_CALL_TRANSACTION_VERIFY", "/TransactionVerify") - BANK_HEALTH_CHECK_ENDPOINT = os.getenv("BANK_HEALTH_CHECK_ENDPOINT", "/system-health-check") - BANK_CALL_AUTH_ENDPOINT = os.getenv("BANK_CALL_AUTH_ENDPOINT", "/Auth/generate-token") - BANK_GRANT_TYPE = os.getenv("BANK_GRANT_TYPE", "password") - TEST_NO = os.getenv("TEST_NO", "2347038224367") - -settings = Config() +import os +from datetime import timedelta + + +class Config: + """Base configuration for Flask app""" + + SECRET_KEY = os.getenv("SECRET_KEY", "supersecretkey") + JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your_jwt_secret") + DEBUG = True + + KAFKA_BROKER = os.getenv("KAFKA_BROKER", "dev-events.simbrellang.net:9085") + KAFKA_TOPICS = [topic.strip() for topic in os.getenv("KAFKA_TOPICS", "PROCESS_PAYMENT,LOAN_REPAYMENT").split(",") if topic.strip()] + KAFKA_TIMEOUT = float( os.getenv("KAFKA_TIMEOUT", 1000.0) ) + + 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) + ) + + BANK_CALL_APP_ID = os.getenv("BANK_CALL_APP_ID", "app1") + BANK_CALL_API_KEY = os.getenv("BANK_CALL_API_KEY", "testtest-api-key-12345") + BANK_CALL_BASIC_AUTH_USERNAME = os.environ.get( + "BANK_CALL_BASIC_AUTH_USERNAME", "simbrella" + ) + BANK_CALL_BASIC_AUTH_PASSWORD = os.environ.get( + "BANK_CALL_BASIC_AUTH_PASSWORD", "G7$k9@pL2!qR" + ) + + DATABASE_USER = os.getenv("DATABASE_USER") + DATABASE_PASSWORD = os.getenv("DATABASE_PASSWORD") + DATABASE_HOST = os.getenv("DATABASE_HOST") + DATABASE_NAME = os.getenv("DATABASE_NAME") + DATABASE_PORT = os.getenv("DATABASE_PORT", 10532) + DATABASE_SID = os.environ.get("DATABASE_SID", "FREE") + DNS = f"(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST={DATABASE_HOST})(PORT={DATABASE_PORT}))(CONNECT_DATA=(SID={DATABASE_SID})))" + + + SQLALCHEMY_DATABASE_URI_INTERNAL = (f"oracle+oracledb://{DATABASE_USER}:{DATABASE_PASSWORD}@{DNS}") + #SQLALCHEMY_DATABASE_URI_INTERNAL = (f"oracle+oracledb://{DATABASE_USER}:{DATABASE_PASSWORD}@{DNS}") + SQLALCHEMY_DATABASE_URI = os.getenv("SQLALCHEMY_DATABASE_URI_FULL", SQLALCHEMY_DATABASE_URI_INTERNAL) + + # SQLALCHEMY_DATABASE_URI = f"postgresql+psycopg2://{DATABASE_USER}:{DATABASE_PASSWORD}@{DATABASE_HOST}:{DATABASE_PORT}/{DATABASE_NAME}" + SQLALCHEMY_TRACK_MODIFICATIONS = False + # SQLALCHEMY_ECHO = True + OVERRIDE_COLLECTION_TRANCATION_ID = int(os.getenv("OVERRIDE_COLLECTION_TRANCATION_ID", 100)) + + + MAIL_SERVER = os.getenv('MAIL_SERVER','smtp.zoho.com') + MAIL_PORT = os.getenv('MAIL_PORT', 587) + MAIL_USERNAME = os.getenv('MAIL_USERNAME', 'firstadvance@dynamikservices.tech') + MAIL_PASSWORD = os.getenv('MAIL_PASSWORD') + MAIL_USE_TLS = os.getenv('MAIL_USE_TLS', 'True').lower() in ('true', '1', 'yes') + MAIL_USE_SSL = os.getenv('MAIL_USE_SSL', 'False').lower() in ('true', '1', 'yes') + MAIL_DEFAULT_SENDER = ('FirstAdvance', 'firstadvance@dynamikservices.tech') + MAIL_RECEIVER= os.getenv('MAIL_RECEIVER', 'chinenyeumeaku@gmail.com,umeakuchinenye@gmail.com') + + + # Processing Overdue LOANS sections + OVERDUE_LOAN_BATCH_SIZE = int(os.getenv("OVERDUE_LOAN_BATCH_SIZE", 10)) + OVERDUE_LOAN_DELAY_SECONDS = int(os.getenv("OVERDUE_LOAN_DELAY_SECONDS", 5)) + OVERDUE_LOAN_BATCH_DELAY_SECONDS = int( + os.getenv("OVERDUE_LOAN_BATCH_DELAY_SECONDS", 5) + ) + OVERDUE_GRACE_PERIOD_DAYS = int(os.getenv("OVERDUE_GRACE_PERIOD_DAYS", 30)) + OVERDUE_PROCESSING_LIST_LIMIT = int(os.getenv("OVERDUE_PROCESSING_LIST_LIMIT", 100)) + PENAL_CHARGE_PERCENTAGE = os.getenv("PENAL_CHARGE_PERCENTAGE", 1) + PENAL_CHARGE_INTERVAL_DAYS = os.getenv("PENAL_CHARGE_INTERVAL_DAYS", 30) + PENAL_CHARGE_MAXIMUM_COUNT = os.getenv("PENAL_CHARGE_MAXIMUM_COUNT", 6) + + + BANK_CALL_API_TIME_OUT = os.getenv("BANK_CALL_API_TIME_OUT", 100) + BANK_CALL_BASE_URL = os.getenv("BANK_CALL_BASE_URL", "https://bank-emulator.dev.simbrellang.net/api") + BANK_CALL_SMS_BASE_URL= os.getenv("BANK_CALL_SMS_BASE_URL","https://first-advance-middleware-develop.fbn-devops-dev-asenv.appserviceenvironment.net/SMS") + BANK_CALL_DISBURSE_LOAN_ENDPOINT = os.getenv("BANK_CALL_DISBURSE_LOAN_ENDPOINT","/DisburseLoan") + BANK_CALL_COLLECT_LOAN_ENDPOINT = os.getenv("BANK_CALL_COLLECT_LOAN_ENDPOINT","/CollectLoan") + BANK_CALL_TRANSACTION_VERIFY = os.getenv("BANK_CALL_TRANSACTION_VERIFY", "/TransactionVerify") + BANK_HEALTH_CHECK_ENDPOINT = os.getenv("BANK_HEALTH_CHECK_ENDPOINT", "/system-health-check") + BANK_CALL_AUTH_ENDPOINT = os.getenv("BANK_CALL_AUTH_ENDPOINT", "/Auth/generate-token") + BANK_GRANT_TYPE = os.getenv("BANK_GRANT_TYPE", "password") + TEST_NO = os.getenv("TEST_NO", "2347038224367") + +settings = Config() diff --git a/app/enums/__init__.py b/app/enums/__init__.py index 8c57c26..8fe89f0 100644 --- a/app/enums/__init__.py +++ b/app/enums/__init__.py @@ -1,3 +1,3 @@ -from .transaction_type import TransactionType -from .loan_status import LoanStatus -from .repayment_schedule_status import RepaymentScheduleStatus +from .transaction_type import TransactionType +from .loan_status import LoanStatus +from .repayment_schedule_status import RepaymentScheduleStatus diff --git a/app/enums/loan_status.py b/app/enums/loan_status.py index fa9f0c5..a75511d 100644 --- a/app/enums/loan_status.py +++ b/app/enums/loan_status.py @@ -1,8 +1,8 @@ -from enum import Enum - -class LoanStatus(str, Enum): - PENDING = "pending" - ACTIVE = "active" - ACTIVE_PARTIAL = "active_partial" - START_REPAY = "start_repay" +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/enums/repayment_schedule_status.py b/app/enums/repayment_schedule_status.py index b67c3ed..f006c43 100644 --- a/app/enums/repayment_schedule_status.py +++ b/app/enums/repayment_schedule_status.py @@ -1,7 +1,7 @@ -from enum import Enum - -class RepaymentScheduleStatus(str, Enum): - PARTIALLY_PAID = "partially_paid" - REPAID = "repaid" - ACTIVE = "active" +from enum import Enum + +class RepaymentScheduleStatus(str, Enum): + PARTIALLY_PAID = "partially_paid" + REPAID = "repaid" + ACTIVE = "active" OVERDUE = "overdue" \ No newline at end of file diff --git a/app/enums/transaction_type.py b/app/enums/transaction_type.py index 7d14546..44d40ce 100644 --- a/app/enums/transaction_type.py +++ b/app/enums/transaction_type.py @@ -1,10 +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" +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/extensions.py b/app/extensions.py index 1afa3d6..8958ac1 100644 --- a/app/extensions.py +++ b/app/extensions.py @@ -1,5 +1,5 @@ -from flask_sqlalchemy import SQLAlchemy -from flask_mail import Mail - -mail = Mail() +from flask_sqlalchemy import SQLAlchemy +from flask_mail import Mail + +mail = Mail() db = SQLAlchemy() \ No newline at end of file diff --git a/app/helpers/collect_loan_helper.py b/app/helpers/collect_loan_helper.py index 73e04b1..6fa6dd8 100644 --- a/app/helpers/collect_loan_helper.py +++ b/app/helpers/collect_loan_helper.py @@ -1,63 +1,63 @@ -import random -import string -from app.services.repayment import RepaymentService -from app.services.loan import LoanService -from app.helpers.response_helper import ResponseHelper -from app.utils.logger import logger -from app.config import settings - -OVERRIDE_COLLECTION_TRANCATION_ID = settings.OVERRIDE_COLLECTION_TRANCATION_ID - -class CollectLoanHelper: - @staticmethod - def _validate_repayment_and_loan(data): - repayment = RepaymentService.get_repayment_by_id(id=data['Id']) - if not repayment: - logger.info(f"Repayment id: {data['Id']}, was not found") - return None, None, ResponseHelper.error("Repayment not found") - - repayment_data = repayment.to_dict() - loan = LoanService.get_loan_by_loan_id(loan_id=int(repayment_data['loanId'])) - if not loan: - logger.info(f"Loan id: {repayment_data['loanId']}, was not found") - return None, None, ResponseHelper.error("Loan not found") - - loan - return repayment_data, loan, None - - @staticmethod - def _build_collect_loan_payload(loan_data, repayment_data, data, collectionMethod): - logger.info(f"building CollectLoan endpoint with data: {loan_data}") - - debtId = str(loan_data.get('debtId', "")).strip().zfill(6) - #this can be overridden based on config - t_id = ''.join(random.choices(string.ascii_uppercase, k=22)) - if OVERRIDE_COLLECTION_TRANCATION_ID == 100: - t_id = loan_data['transactionId'] - return { - "transactionId": t_id, - "fbnTransactionId": loan_data['transactionId'], - "debtId": debtId, - "customerId": repayment_data['customerId'], - "accountId": loan_data['accountId'], - "productId": repayment_data['productId'], - "collectAmount": ( - data['overdueLoanScheduleAmount'] - if data.get('overdueLoanScheduleAmount') - is not None else loan_data.get('balance', 0) - ), - "penalCharge": 0, - "channel": "USSD", - "collectionMethod": collectionMethod, - "lienAmount": 0, - "countryId": "NG", - "comment": "COLLECT LOAN" - } - - @staticmethod - def chunk_list(data, chunk_size): - """Yield successive chunk_size chunks from data.""" - for i in range(0, len(data), chunk_size): - yield data[i:i + chunk_size] - +import random +import string +from app.services.repayment import RepaymentService +from app.services.loan import LoanService +from app.helpers.response_helper import ResponseHelper +from app.utils.logger import logger +from app.config import settings + +OVERRIDE_COLLECTION_TRANCATION_ID = settings.OVERRIDE_COLLECTION_TRANCATION_ID + +class CollectLoanHelper: + @staticmethod + def _validate_repayment_and_loan(data): + repayment = RepaymentService.get_repayment_by_id(id=data['Id']) + if not repayment: + logger.info(f"Repayment id: {data['Id']}, was not found") + return None, None, ResponseHelper.error("Repayment not found") + + repayment_data = repayment.to_dict() + loan = LoanService.get_loan_by_loan_id(loan_id=int(repayment_data['loanId'])) + if not loan: + logger.info(f"Loan id: {repayment_data['loanId']}, was not found") + return None, None, ResponseHelper.error("Loan not found") + + loan + return repayment_data, loan, None + + @staticmethod + def _build_collect_loan_payload(loan_data, repayment_data, data, collectionMethod): + logger.info(f"building CollectLoan endpoint with data: {loan_data}") + + debtId = str(loan_data.get('debtId', "")).strip().zfill(6) + #this can be overridden based on config + t_id = ''.join(random.choices(string.ascii_uppercase, k=22)) + if OVERRIDE_COLLECTION_TRANCATION_ID == 100: + t_id = loan_data['transactionId'] + return { + "transactionId": t_id, + "fbnTransactionId": loan_data['transactionId'], + "debtId": debtId, + "customerId": repayment_data['customerId'], + "accountId": loan_data['accountId'], + "productId": repayment_data['productId'], + "collectAmount": ( + data['overdueLoanScheduleAmount'] + if data.get('overdueLoanScheduleAmount') + is not None else loan_data.get('balance', 0) + ), + "penalCharge": 0, + "channel": "USSD", + "collectionMethod": collectionMethod, + "lienAmount": 0, + "countryId": "NG", + "comment": "COLLECT LOAN" + } + + @staticmethod + def chunk_list(data, chunk_size): + """Yield successive chunk_size chunks from data.""" + for i in range(0, len(data), chunk_size): + yield data[i:i + chunk_size] + \ No newline at end of file diff --git a/app/helpers/response_helper.py b/app/helpers/response_helper.py index adcc178..3b676c6 100644 --- a/app/helpers/response_helper.py +++ b/app/helpers/response_helper.py @@ -1,251 +1,251 @@ -from flask import jsonify -from typing import List, Dict, Union, Optional, Any - - -class ResponseHelper: - """ - A helper class for building standardized JSON responses in Flask. - """ - - @staticmethod - def build_response( - status: bool, - message: str, - data: Optional[Union[Dict, List, str]] = None, - status_code: int = 200, - error: Optional[Union[Dict, str]] = None, - ) -> Dict[str, Any]: - """ - Build a standardized JSON response. - - Args: - status (bool): Indicates whether the request was successful. - message (str): A message describing the result of the request. - data (Optional[Union[Dict, List, str]]): The data to return in the response. - status_code (int): The HTTP status code for the response. - error (Optional[Union[Dict, str]]): Any error details to include in the response. - - Returns: - Dict[str, Any]: A dictionary representing the JSON response. - """ - response = { - "status": status, - "statusCode": status_code, - "message": message, - "data": data if data is not None else {}, - "error": error if error is not None else {}, - } - return jsonify(response), status_code - - @staticmethod - def success( - data: Optional[Union[Dict, List, str]] = None, - message: str = "Successful", - status_code: int = 200, - error: Optional[Union[Dict, str]] = None, - ) -> Dict[str, Any]: - """ - Return a success response. - - Args: - data (Optional[Union[Dict, List, str]]): The data to return in the response. - message (str): A message describing the result of the request. - status_code (int): The HTTP status code for the response. - error (Optional[Union[Dict, str]]): Any error details to include in the response. - - Returns: - Dict[str, Any]: A dictionary representing the JSON response. - """ - return ResponseHelper.build_response(True, message, data, status_code, error) - - @staticmethod - def error( - message: str = "An error occurred", - status_code: int = 400, - data: Optional[Union[Dict, List, str]] = None, - error: Optional[Union[Dict, str]] = None, - ) -> Dict[str, Any]: - """ - Return an error response. - - Args: - message (str): A message describing the error. - status_code (int): The HTTP status code for the response. - data (Optional[Union[Dict, List, str]]): The data to return in the response. - error (Optional[Union[Dict, str]]): Any error details to include in the response. - - Returns: - Dict[str, Any]: A dictionary representing the JSON response. - """ - return ResponseHelper.build_response(False, message, data, status_code, error) - - @staticmethod - def created( - data: Optional[Union[Dict, List, str]] = None, - message: str = "Resource created successfully", - error: Optional[Union[Dict, str]] = None, - ) -> Dict[str, Any]: - """ - Return a response for a created resource. - - Args: - data (Optional[Union[Dict, List, str]]): The data to return in the response. - message (str): A message describing the result of the request. - error (Optional[Union[Dict, str]]): Any error details to include in the response. - - Returns: - Dict[str, Any]: A dictionary representing the JSON response. - """ - return ResponseHelper.build_response(True, message, data, 201, error) - - @staticmethod - def updated( - data: Optional[Union[Dict, List, str]] = None, - message: str = "Resource updated successfully", - error: Optional[Union[Dict, str]] = None, - ) -> Dict[str, Any]: - """ - Return a response for an updated resource. - - Args: - data (Optional[Union[Dict, List, str]]): The data to return in the response. - message (str): A message describing the result of the request. - error (Optional[Union[Dict, str]]): Any error details to include in the response. - - Returns: - Dict[str, Any]: A dictionary representing the JSON response. - """ - return ResponseHelper.build_response(True, message, data, 200, error) - - @staticmethod - def internal_server_error( - message: str = "Internal Server Error", - data: Optional[Union[Dict, List, str]] = None, - error: Optional[Union[Dict, str]] = None, - ) -> Dict[str, Any]: - """ - Return a response for an internal server error. - - Args: - message (str): A message describing the error. - data (Optional[Union[Dict, List, str]]): The data to return in the response. - error (Optional[Union[Dict, str]]): Any error details to include in the response. - - Returns: - Dict[str, Any]: A dictionary representing the JSON response. - """ - return ResponseHelper.build_response(False, message, data, 500, error) - - @staticmethod - def unauthorized( - message: str = "Unauthorized", - data: Optional[Union[Dict, List, str]] = None, - error: Optional[Union[Dict, str]] = None, - ) -> Dict[str, Any]: - """ - Return a response for an unauthorized request. - - Args: - message (str): A message describing the error. - data (Optional[Union[Dict, List, str]]): The data to return in the response. - error (Optional[Union[Dict, str]]): Any error details to include in the response. - - Returns: - Dict[str, Any]: A dictionary representing the JSON response. - """ - return ResponseHelper.build_response(False, message, data, 401, error) - - @staticmethod - def forbidden( - message: str = "Forbidden", - data: Optional[Union[Dict, List, str]] = None, - error: Optional[Union[Dict, str]] = None, - ) -> Dict[str, Any]: - """ - Return a response for a forbidden request. - - Args: - message (str): A message describing the error. - data (Optional[Union[Dict, List, str]]): The data to return in the response. - error (Optional[Union[Dict, str]]): Any error details to include in the response. - - Returns: - Dict[str, Any]: A dictionary representing the JSON response. - """ - return ResponseHelper.build_response(False, message, data, 403, error) - - @staticmethod - def not_found( - message: str = "Resource not found", - data: Optional[Union[Dict, List, str]] = None, - error: Optional[Union[Dict, str]] = None, - ) -> Dict[str, Any]: - """ - Return a response for a not found resource. - - Args: - message (str): A message describing the error. - data (Optional[Union[Dict, List, str]]): The data to return in the response. - error (Optional[Union[Dict, str]]): Any error details to include in the response. - - Returns: - Dict[str, Any]: A dictionary representing the JSON response. - """ - return ResponseHelper.build_response(False, message, data, 404, error) - - @staticmethod - def unprocessable_entity( - message: str = "Unprocessable entity", - data: Optional[Union[Dict, List, str]] = None, - error: Optional[Union[Dict, str]] = None, - ) -> Dict[str, Any]: - """ - Return a response for an unprocessable entity. - - Args: - message (str): A message describing the error. - data (Optional[Union[Dict, List, str]]): The data to return in the response. - error (Optional[Union[Dict, str]]): Any error details to include in the response. - - Returns: - Dict[str, Any]: A dictionary representing the JSON response. - """ - return ResponseHelper.build_response(False, message, data, 422, error) - - @staticmethod - def method_not_allowed( - message: str = "Method Not Allowed", - data: Optional[Union[Dict, List, str]] = None, - error: Optional[Union[Dict, str]] = None, - ) -> Dict[str, Any]: - """ - Return a response for a method not allowed error. - - Args: - message (str): A message describing the error. - data (Optional[Union[Dict, List, str]]): The data to return in the response. - error (Optional[Union[Dict, str]]): Any error details to include in the response. - - Returns: - Dict[str, Any]: A dictionary representing the JSON response. - """ - return ResponseHelper.build_response(False, message, data, 405, error) - - @staticmethod - def bad_request( - message: str = "Bad Request", - data: Optional[Union[Dict, List, str]] = None, - error: Optional[Union[Dict, str]] = None, - ) -> Dict[str, Any]: - """ - Return a response for a bad request error. - - Args: - message (str): A message describing the error. - data (Optional[Union[Dict, List, str]]): The data to return in the response. - error (Optional[Union[Dict, str]]): Any error details to include in the response. - - Returns: - Dict[str, Any]: A dictionary representing the JSON response. - """ +from flask import jsonify +from typing import List, Dict, Union, Optional, Any + + +class ResponseHelper: + """ + A helper class for building standardized JSON responses in Flask. + """ + + @staticmethod + def build_response( + status: bool, + message: str, + data: Optional[Union[Dict, List, str]] = None, + status_code: int = 200, + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Build a standardized JSON response. + + Args: + status (bool): Indicates whether the request was successful. + message (str): A message describing the result of the request. + data (Optional[Union[Dict, List, str]]): The data to return in the response. + status_code (int): The HTTP status code for the response. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + response = { + "status": status, + "statusCode": status_code, + "message": message, + "data": data if data is not None else {}, + "error": error if error is not None else {}, + } + return jsonify(response), status_code + + @staticmethod + def success( + data: Optional[Union[Dict, List, str]] = None, + message: str = "Successful", + status_code: int = 200, + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Return a success response. + + Args: + data (Optional[Union[Dict, List, str]]): The data to return in the response. + message (str): A message describing the result of the request. + status_code (int): The HTTP status code for the response. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + return ResponseHelper.build_response(True, message, data, status_code, error) + + @staticmethod + def error( + message: str = "An error occurred", + status_code: int = 400, + data: Optional[Union[Dict, List, str]] = None, + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Return an error response. + + Args: + message (str): A message describing the error. + status_code (int): The HTTP status code for the response. + data (Optional[Union[Dict, List, str]]): The data to return in the response. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + return ResponseHelper.build_response(False, message, data, status_code, error) + + @staticmethod + def created( + data: Optional[Union[Dict, List, str]] = None, + message: str = "Resource created successfully", + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Return a response for a created resource. + + Args: + data (Optional[Union[Dict, List, str]]): The data to return in the response. + message (str): A message describing the result of the request. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + return ResponseHelper.build_response(True, message, data, 201, error) + + @staticmethod + def updated( + data: Optional[Union[Dict, List, str]] = None, + message: str = "Resource updated successfully", + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Return a response for an updated resource. + + Args: + data (Optional[Union[Dict, List, str]]): The data to return in the response. + message (str): A message describing the result of the request. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + return ResponseHelper.build_response(True, message, data, 200, error) + + @staticmethod + def internal_server_error( + message: str = "Internal Server Error", + data: Optional[Union[Dict, List, str]] = None, + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Return a response for an internal server error. + + Args: + message (str): A message describing the error. + data (Optional[Union[Dict, List, str]]): The data to return in the response. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + return ResponseHelper.build_response(False, message, data, 500, error) + + @staticmethod + def unauthorized( + message: str = "Unauthorized", + data: Optional[Union[Dict, List, str]] = None, + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Return a response for an unauthorized request. + + Args: + message (str): A message describing the error. + data (Optional[Union[Dict, List, str]]): The data to return in the response. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + return ResponseHelper.build_response(False, message, data, 401, error) + + @staticmethod + def forbidden( + message: str = "Forbidden", + data: Optional[Union[Dict, List, str]] = None, + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Return a response for a forbidden request. + + Args: + message (str): A message describing the error. + data (Optional[Union[Dict, List, str]]): The data to return in the response. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + return ResponseHelper.build_response(False, message, data, 403, error) + + @staticmethod + def not_found( + message: str = "Resource not found", + data: Optional[Union[Dict, List, str]] = None, + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Return a response for a not found resource. + + Args: + message (str): A message describing the error. + data (Optional[Union[Dict, List, str]]): The data to return in the response. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + return ResponseHelper.build_response(False, message, data, 404, error) + + @staticmethod + def unprocessable_entity( + message: str = "Unprocessable entity", + data: Optional[Union[Dict, List, str]] = None, + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Return a response for an unprocessable entity. + + Args: + message (str): A message describing the error. + data (Optional[Union[Dict, List, str]]): The data to return in the response. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + return ResponseHelper.build_response(False, message, data, 422, error) + + @staticmethod + def method_not_allowed( + message: str = "Method Not Allowed", + data: Optional[Union[Dict, List, str]] = None, + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Return a response for a method not allowed error. + + Args: + message (str): A message describing the error. + data (Optional[Union[Dict, List, str]]): The data to return in the response. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ + return ResponseHelper.build_response(False, message, data, 405, error) + + @staticmethod + def bad_request( + message: str = "Bad Request", + data: Optional[Union[Dict, List, str]] = None, + error: Optional[Union[Dict, str]] = None, + ) -> Dict[str, Any]: + """ + Return a response for a bad request error. + + Args: + message (str): A message describing the error. + data (Optional[Union[Dict, List, str]]): The data to return in the response. + error (Optional[Union[Dict, str]]): Any error details to include in the response. + + Returns: + Dict[str, Any]: A dictionary representing the JSON response. + """ return ResponseHelper.build_response(False, message, data, 400, error) \ No newline at end of file diff --git a/app/integrations/__init__.py b/app/integrations/__init__.py index 5327474..3f02dd4 100644 --- a/app/integrations/__init__.py +++ b/app/integrations/__init__.py @@ -1,3 +1,3 @@ -from .kafka import KafkaIntegration -from .simbrella import SimbrellaClient +from .kafka import KafkaIntegration +from .simbrella import SimbrellaClient from .bank_service import BankService \ No newline at end of file diff --git a/app/integrations/bank_service.py b/app/integrations/bank_service.py index f9e8ad3..ca4dba9 100644 --- a/app/integrations/bank_service.py +++ b/app/integrations/bank_service.py @@ -1,25 +1,25 @@ -import requests -from app.config import settings -from app.utils.auth import get_headers -from app.utils.logger import logger - - -class BankService: - BANK_CALL_BASE_URL = settings.BANK_CALL_BASE_URL - BANK_HEALTH_CHECK_ENDPOINT = settings.BANK_HEALTH_CHECK_ENDPOINT - BANK_CALL_APP_ID = settings.BANK_CALL_APP_ID - - @staticmethod - def health_check(): - api_url = f"{BankService.BANK_CALL_BASE_URL}{BankService.BANK_HEALTH_CHECK_ENDPOINT}" - logger.info(f"Calling Health Check endpoint: {api_url}") - - try: - response = requests.get(api_url, timeout=5, headers=get_headers()) - logger.info(f"Health Check response status code: {response.status_code}") - - return response.json() - - except Exception as e: - logger.error(f"Health Check API call failed: {str(e)}", exc_info=True) +import requests +from app.config import settings +from app.utils.auth import get_headers +from app.utils.logger import logger + + +class BankService: + BANK_CALL_BASE_URL = settings.BANK_CALL_BASE_URL + BANK_HEALTH_CHECK_ENDPOINT = settings.BANK_HEALTH_CHECK_ENDPOINT + BANK_CALL_APP_ID = settings.BANK_CALL_APP_ID + + @staticmethod + def health_check(): + api_url = f"{BankService.BANK_CALL_BASE_URL}{BankService.BANK_HEALTH_CHECK_ENDPOINT}" + logger.info(f"Calling Health Check endpoint: {api_url}") + + try: + response = requests.get(api_url, timeout=5, headers=get_headers()) + logger.info(f"Health Check response status code: {response.status_code}") + + return response.json() + + except Exception as e: + logger.error(f"Health Check API call failed: {str(e)}", exc_info=True) raise \ No newline at end of file diff --git a/app/integrations/kafka.py b/app/integrations/kafka.py index 55298d3..cc3392c 100644 --- a/app/integrations/kafka.py +++ b/app/integrations/kafka.py @@ -1,156 +1,156 @@ -from confluent_kafka import Consumer, Producer -import json -from app.utils.logger import logger -from app.config import settings -import requests -from app.integrations.simbrella import SimbrellaClient - - -class KafkaIntegration: - - BASE_URL = settings.BANK_CALL_BASE_URL - - _consumer = None - - _consumer_config = { - "bootstrap.servers": settings.KAFKA_BROKER, - "group.id": "loan-service-consumer", - "auto.offset.reset": "earliest", - "enable.auto.commit": True, - } - - @staticmethod - def _get_consumer(): - """Kafka consumer""" - if not KafkaIntegration._consumer: - KafkaIntegration._consumer = Consumer(KafkaIntegration._consumer_config) - logger.info( - f"Consumer connected to Kafka broker at {KafkaIntegration._consumer_config['bootstrap.servers']}" - ) - return KafkaIntegration._consumer - - @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 receive_messages(topics, timeout): - """ - Receive messages from a Kafka topic. - - :param topics: The Kafka topics to subscribe to - :param timeout: Time to wait for a message (in seconds) - :return: The message value (decoded) or None if no message is received - """ - consumer = KafkaIntegration._get_consumer() - consumer.subscribe(topics) - - - logger.info( - f"Waiting for messages from topic {topics} with this timeout: {timeout}..." - ) - message = [] - try: - msg = consumer.poll(timeout=timeout) - - logger.info(str(msg.value)) - - logger.info( - f"Received message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: " - ) - - if msg is None: - logger.info(f"No message received from topic {topics} within timeout") - return None - if msg.error(): - logger.info(f"Consumer error: {msg.error()}") - raise RuntimeError(f"Consumer error: {msg.error()}") - - # Decode and return the message value - message_value= {"value":''} - if msg.value() != "": - message_value = msg.value().decode("utf-8") - logger.info( - f"Received message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: {message_value}" - ) - - # Invalid Json will stop this process - try: - message = json.loads(message_value) if message_value else None - except ValueError as e: - return message # Invalid JSON JUST TURN BACK HERE - else: - pass # valid json - - # Call the endpoint if provided - if message: - logger.info( - f"Received message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: {message}" - ) - current_topic = msg.topic() - - if current_topic=="PROCESS_PAYMENT": - KafkaIntegration._call_disbursement_service(message) - - if current_topic=="LOAN_REPAYMENT": - KafkaIntegration._call_collect_loan_service(message) - logger.info( - f"Loan Repayment message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: {message}" - ) - - return message - - except Exception as e: - logger.error(f"Error while receiving message: {e}") - raise - - #return [] - - @staticmethod - def close_consumer(): - """Shutdown consumer""" - consumer = KafkaIntegration._get_consumer() - consumer.close() - logger.info("Kafka consumer closed") - - @staticmethod - def _call_disbursement_service(message): - """Call the disbursement service with the received message""" - logger.info(f"Calling disbursement service with message: {message}") - - try: - response = SimbrellaClient.disburse_loan(message) - logger.info( - f"Successfully sent message to disbursement service: {response}" - ) - - # LoanService.set_disbursement_date(loan_id=loan_data['debtId'], - # customer_id=customerId) # must mark it on way out - # - - except Exception as e: - logger.info(f"Failed to call disbursement service: {e}") - #raise - - @staticmethod - def _call_collect_loan_service(message): - """Call the collect loan service with the received message""" - logger.info(f"Calling collect_loan service with message: {message}") - - try: - #Calling CollectLoan endpoint with data: {'transactionId': 'TRCVIC85641527829', 'customerId': 'ZX48440946', 'productId': 'AMPC', 'loanRef': 'TRCVIC85641527829USSDAMPC', 'debtId': '014231'} - response = SimbrellaClient.collect_loan_user_initiated(message) - logger.info( - f"Successfully sent message to collect_loan service: {response}" - ) - except Exception as e: - logger.error(f"Failed to call collect_loan service: {e}") - # raise +from confluent_kafka import Consumer, Producer +import json +from app.utils.logger import logger +from app.config import settings +import requests +from app.integrations.simbrella import SimbrellaClient + + +class KafkaIntegration: + + BASE_URL = settings.BANK_CALL_BASE_URL + + _consumer = None + + _consumer_config = { + "bootstrap.servers": settings.KAFKA_BROKER, + "group.id": "loan-service-consumer", + "auto.offset.reset": "earliest", + "enable.auto.commit": True, + } + + @staticmethod + def _get_consumer(): + """Kafka consumer""" + if not KafkaIntegration._consumer: + KafkaIntegration._consumer = Consumer(KafkaIntegration._consumer_config) + logger.info( + f"Consumer connected to Kafka broker at {KafkaIntegration._consumer_config['bootstrap.servers']}" + ) + return KafkaIntegration._consumer + + @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 receive_messages(topics, timeout): + """ + Receive messages from a Kafka topic. + + :param topics: The Kafka topics to subscribe to + :param timeout: Time to wait for a message (in seconds) + :return: The message value (decoded) or None if no message is received + """ + consumer = KafkaIntegration._get_consumer() + consumer.subscribe(topics) + + + logger.info( + f"Waiting for messages from topic {topics} with this timeout: {timeout}..." + ) + message = [] + try: + msg = consumer.poll(timeout=timeout) + + logger.info(str(msg.value)) + + logger.info( + f"Received message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: " + ) + + if msg is None: + logger.info(f"No message received from topic {topics} within timeout") + return None + if msg.error(): + logger.info(f"Consumer error: {msg.error()}") + raise RuntimeError(f"Consumer error: {msg.error()}") + + # Decode and return the message value + message_value= {"value":''} + if msg.value() != "": + message_value = msg.value().decode("utf-8") + logger.info( + f"Received message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: {message_value}" + ) + + # Invalid Json will stop this process + try: + message = json.loads(message_value) if message_value else None + except ValueError as e: + return message # Invalid JSON JUST TURN BACK HERE + else: + pass # valid json + + # Call the endpoint if provided + if message: + logger.info( + f"Received message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: {message}" + ) + current_topic = msg.topic() + + if current_topic=="PROCESS_PAYMENT": + KafkaIntegration._call_disbursement_service(message) + + if current_topic=="LOAN_REPAYMENT": + KafkaIntegration._call_collect_loan_service(message) + logger.info( + f"Loan Repayment message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: {message}" + ) + + return message + + except Exception as e: + logger.error(f"Error while receiving message: {e}") + raise + + #return [] + + @staticmethod + def close_consumer(): + """Shutdown consumer""" + consumer = KafkaIntegration._get_consumer() + consumer.close() + logger.info("Kafka consumer closed") + + @staticmethod + def _call_disbursement_service(message): + """Call the disbursement service with the received message""" + logger.info(f"Calling disbursement service with message: {message}") + + try: + response = SimbrellaClient.disburse_loan(message) + logger.info( + f"Successfully sent message to disbursement service: {response}" + ) + + # LoanService.set_disbursement_date(loan_id=loan_data['debtId'], + # customer_id=customerId) # must mark it on way out + # + + except Exception as e: + logger.info(f"Failed to call disbursement service: {e}") + #raise + + @staticmethod + def _call_collect_loan_service(message): + """Call the collect loan service with the received message""" + logger.info(f"Calling collect_loan service with message: {message}") + + try: + #Calling CollectLoan endpoint with data: {'transactionId': 'TRCVIC85641527829', 'customerId': 'ZX48440946', 'productId': 'AMPC', 'loanRef': 'TRCVIC85641527829USSDAMPC', 'debtId': '014231'} + response = SimbrellaClient.collect_loan_user_initiated(message) + logger.info( + f"Successfully sent message to collect_loan service: {response}" + ) + except Exception as e: + logger.error(f"Failed to call collect_loan service: {e}") + # raise diff --git a/app/integrations/simbrella.py b/app/integrations/simbrella.py index 57cf740..d1c0005 100644 --- a/app/integrations/simbrella.py +++ b/app/integrations/simbrella.py @@ -1,545 +1,545 @@ -import requests -from app.config import settings -from app.helpers.response_helper import ResponseHelper -# from app.routes.autocall import verify_transaction -from app.models.customer import Customer -from app.services.loan import LoanService -from app.services.loan_repayment_schedule import LoanRepaymentScheduleService -from app.utils.auth import get_headers -from app.utils.extras import preprocess_loan_charges_data -import random -import string -from app.extensions import db -from app.utils.logger import logger -from flask import jsonify, current_app -from app.services.transactions import TransactionService -from app.services.repayment import RepaymentService -from app.extensions import db -from app.services.repayments_data import RepaymentsData -from app.services.salary import SalaryService -from app.enums.loan_status import LoanStatus -from app.models.loan_repayment_schedule import LoanRepaymentSchedule -from decimal import Decimal, ROUND_HALF_UP -from requests.exceptions import SSLError, RequestException, Timeout, ReadTimeout, ConnectTimeout -import sys -import socket -from app.helpers.collect_loan_helper import CollectLoanHelper - - -class SimbrellaClient: - BANK_CALL_BASE_URL = settings.BANK_CALL_BASE_URL - BANK_CALL_SMS_BASE_URL = settings.BANK_CALL_SMS_BASE_URL - BANK_CALL_DISBURSE_LOAN_ENDPOINT = settings.BANK_CALL_DISBURSE_LOAN_ENDPOINT - BANK_CALL_COLLECT_LOAN_ENDPOINT = settings.BANK_CALL_COLLECT_LOAN_ENDPOINT - BANK_CALL_TRANSACTION_VERIFY = settings.BANK_CALL_TRANSACTION_VERIFY - BANK_HEALTH_CHECK_ENDPOINT = settings.BANK_HEALTH_CHECK_ENDPOINT - BANK_CALL_API_TIME_OUT = settings.BANK_CALL_API_TIME_OUT - - @staticmethod - def disburse_loan(data): - api_url = f"{SimbrellaClient.BANK_CALL_BASE_URL}{SimbrellaClient.BANK_CALL_DISBURSE_LOAN_ENDPOINT}" - logger.info(f"Calling DisburseLoan api_url==> : {api_url}") - logger.info(f"Calling DisburseLoan endpoint with data: {data}") - - # Check if the transaction exists - logger.info(f"Checking if transaction exists") - transaction = TransactionService.get_transaction_by_transaction_id(transaction_id=data['transactionId']) - logger.info(f"Loan Response From Database ** : {transaction}") - - # If transaction is not found - if not transaction: - logger.info(f"Transaction id: {data['transactionId']}, was not found") - return 0 - - # Fetch the loan based on the transaction_id - logger.info(f"Fetching the loan with transaction ID: {data['transactionId']}") - loan = LoanService.get_loan_by_transaction_id(transaction_id=data['transactionId']) - logger.info(f"Response from database: {loan}") - - # If loan is not found - if not loan: - logger.info(f"Could not find loan with transaction id: {data['transactionId']}") - return 0 - - loan_data = loan.to_dict() - logger.info(f"Here is your loan data: {loan_data}") - - if loan_data['status'] != LoanStatus.ACTIVE: - logger.info(f"Loan with transaction id: {data['transactionId']} is not active") - return 0 - - if loan_data['disburseDate'] is not None: - logger.info("*************************SEEM LIKE A RETRY CALL -WE WILL VERIFY Result to Continue ") - logger.info( - f"Please call verify loan : {data['transactionId']} loan sent for processing at {loan_data['disburseDate']}") - # return 0 -- we need the disburseResult = '00' to be sure all is good - - if loan_data['disburseDate'] is not None and loan_data['disburseResult'] == '00': - logger.info("*************************Duplicate call to completed loan ") - logger.info( - f"Duplicate call detected for loan : {data['transactionId']} loan sent for processing at {loan_data['disburseDate']}") - return 0 - - # let us set disbursement date - LoanService.set_disbursement_date(loan_data['debtId'], - loan_data['customerId']) # toda this must return something - logger.info(f"Here is your loan data after setting disbursement date: {loan_data}") - - loan_charges = preprocess_loan_charges_data([loan_charge.to_dict() for loan_charge in loan.loan_charges]) - logger.info(f"Here are your loan_charges: {loan_charges}") - - mgt_fee = loan_charges.get("MGTFEE")['amount'] - vat_fee = loan_charges.get("VAT")['amount'] - interest_fee = loan_charges.get("INTEREST")['amount'] - insurance_fee = loan_charges.get("INSURANCE")['amount'] - product_id = str(loan_data.get('productId', "")) - debtId = str(loan_data.get('debtId', "")).strip().zfill(6) - disbursement_data = { - "transactionId": loan_data.get('transactionId'), - "fbnTransactionId": loan_data.get('transactionId'), - "debtId": debtId, - "customerId": loan_data.get('customerId'), - "accountId": loan_data.get('accountId'), - "productId": str(loan_data.get('productId', "")), - "provideAmount": loan_data.get('currentLoanAmount'), - "collectAmountInterest": interest_fee if product_id != '3MPC' else 0, - "collectAmountMgtFee": mgt_fee, - "collectAmountInsurance": insurance_fee, - "collectAmountVAT": vat_fee, - "countryId": "01", - "comment": "Loan Disbursement", - } - # ''' - # { Veryfing with the bank - # "transactionId": "string", - # "fbnTransactionId": "string", - # "debtId": "string", - # "customerId": "string", - # "accountId": "string", - # "productId": "string", - # "provideAmount": 0, - # "collectAmountInterest": 0, - # "collectAmountMgtFee": 0, - # "collectAmountInsurance": 0, - # "collectAmountVAT": 0, - # "countryId": "string", - # "comment": "string" - # } - # ''' - - try: - logger.info(f"Calling Bank Disbursement with Request data ****** : {disbursement_data}") - response = requests.post(api_url, json=disbursement_data, timeout=SimbrellaClient.BANK_CALL_API_TIME_OUT, - headers=get_headers()) - logger.info( - f"Call to bank end point returned with Here is your Disbursement Request data ****** : {disbursement_data}") - if response.status_code == 404: - logger.error("") - LoanService.set_disbursement_loan_description(loan_data['debtId'], - "Disbursement Service url not found (404)") - return ResponseHelper.error("Disbursement Service url not found (404)", status_code=404) - - logger.info(f"Disbursement response: {response.json()}") - - if response.status_code == 200: - result = response.json() - LoanService.set_disbursement_result(loan_data['debtId'], result.get('responseCode', ''), - result.get('responseMessage', '')) - reload_loan = LoanService.get_loan_by_transaction_id(transaction_id=data['transactionId']) - reload_loan_data = reload_loan.to_dict() - #mark repayment schedule as active - repayment_schedule = LoanRepaymentScheduleService.get_repayment_schedule_by_loan_id( - reload_loan_data['debtId'], include_paid=False) - logger.info(f'Loan repayment schedule: {repayment_schedule}') - if repayment_schedule: - for schedule in repayment_schedule: - logger.info(f"Updating repayment schedule ID {schedule.id} status to ACTIVE") - LoanRepaymentScheduleService.update_repayment_schedule_status_to_active(schedule.id) - - SimbrellaClient.verify_disbursement_transaction(reload_loan_data) - - return ResponseHelper.success(response.json(), "Successful") - - else: - logger.error("") - errorMessage = "Unable to complete Disbursement Service with HTTP status code: " + str( - response.status_code) - LoanService.set_disbursement_loan_description(loan_data['debtId'], errorMessage) - return ResponseHelper.error(errorMessage, status_code=response.status_code) - - except requests.exceptions.HTTPError as errh: - print(f"Disbursement HTTP Error: {errh}") - except requests.exceptions.ConnectionError as errc: - print(f"Disbursement Error Connecting: {errc}") - except requests.exceptions.Timeout as errt: - print(f"Disbursement Timeout Error: {errt}") - except requests.exceptions.RequestException as err: - print(f"Disbursement - Unexpected Error Occurred: {err}") - except Exception as e: - logger.info(f"Failed to call Disbursement endpoint: {e}") - return 0 - - # try: - # logger.info(f"Calling Bank Disbursement with Request data ****** : {disbursement_data}") - # response = requests.post(api_url, json=disbursement_data, timeout=SimbrellaClient.BANK_CALL_API_TIME_OUT, - # headers=get_headers()) - # logger.info( - # f"Call to bank end point returned with Here is your Disbursement Request data ****** : {disbursement_data}") - # if response.status_code == 404: - # logger.error("") - # LoanService.set_disbursement_loan_description(loan_data['debtId'], - # "Disbursement Service url not found (404)") - # return ResponseHelper.error("Disbursement Service url not found (404)", status_code=404) - # - # logger.info(f"Disbursement response: {response.json()}") - # - # if response.status_code == 200: - # result = response.json() - # LoanService.set_disbursement_result(loan_data['debtId'], result.get('responseCode', ''), - # result.get('responseMessage', '')) - # reload_loan = LoanService.get_loan_by_transaction_id(transaction_id=data['transactionId']) - # reload_loan_data = reload_loan.to_dict() - # SimbrellaClient.verify_disbursement_transaction(reload_loan_data) - # return ResponseHelper.success(response.json(), "Successful") - # - # else: - # logger.error("") - # errorMessage = "Unable to complete Disbursement Service with HTTP status code: " + str( - # response.status_code) - # LoanService.set_disbursement_loan_description(loan_data['debtId'], errorMessage) - # return ResponseHelper.error(errorMessage, status_code=response.status_code) - - # except Exception as e: - # logger.info(f"Failed to call Disbursement endpoint: {e}") - # return 0 - - return 1 - - @staticmethod - def verify_disbursement_transaction(loan_data): - if loan_data['disburseResult'] and loan_data['disburseResult'] == '00': - SimbrellaClient.verify_transaction(loan_data) - - @staticmethod - def verify_transaction(data): - api_url = f"{SimbrellaClient.BANK_CALL_BASE_URL}{SimbrellaClient.BANK_CALL_TRANSACTION_VERIFY}" - sms_url = f"{SimbrellaClient.BANK_CALL_SMS_BASE_URL}/singleSMS" - logger.info(f"Calling TransactionVerify api_url==> : {api_url}") - - # Check if the transaction exists - logger.info(f"Checking if transaction exists") - transaction = TransactionService.get_transaction_by_transaction_id(transaction_id=data['transactionId']) - transaction_data = transaction.to_dict() - logger.info(f"Loan Response From Database ** : {transaction}") - - # If transaction is not found - if not transaction: - logger.info(f"Transaction id: {data['transactionId']}, was not found") - return 0 - - # Fetch the loan based on the transaction_id - logger.info(f"Fetching the loan with transaction ID: {data['transactionId']}") - loan = LoanService.get_loan_by_transaction_id(transaction_id=data['transactionId']) - logger.info(f"Response from database: {loan}") - - # If loan is not found - if not loan: - logger.info(f"Could not find loan with transaction id: {data['transactionId']}") - return 0 - - loan_data = loan.to_dict() - logger.info(f"Here is your loan data: {loan_data}") - - if loan_data['disburseDate'] is not None and loan_data['disburseVerify'] is None: - LoanService.set_disburse_verify_date(loan_data['debtId'], loan_data['customerId']) - loan_data = loan.to_dict() - logger.info(f"Here is your loan data after setting verify date: {loan_data}") - logger.info(f"Good to Verify transaction id: {data['transactionId']}") - else: - logger.info( - f"Please call disburse loan : {data['transactionId']} loan send for processing first") - return 0 - - verify_data = { - "customerId": loan_data.get('customerId'), - "accountId": loan_data.get('accountId'), - "transactionId": loan_data.get('transactionId'), - "transactionType": "provide", - "fbnTransactionId": loan_data.get('transactionId'), - "countryId": "NG", - "requestId": loan_data.get('transactionId') - } - # ''' - # { Verify with bank - # "accountId": "string", - # "customerId": "string", - # "transactionId": "string", - # "fbnTransactionId": "string", - # "transactionType": "string", - # "countryId": "string", - # "requestId": "string" - # } - # ''' - try: - logger.info(f"Here is your TransactionVerify Request data ****** : {verify_data}") - response = requests.post(api_url, json=verify_data, timeout=SimbrellaClient.BANK_CALL_API_TIME_OUT, - headers=get_headers()) - if response.status_code == 404: - logger.error("Received 404 from external service") - return ResponseHelper.error("Verify Service url not found (404)", status_code=404) - result = response.json() - #check for res 00 and status 200 - logger.info(f"this is verify result, {result}") - LoanService.set_disburse_verify_result(loan_data['debtId'], result.get('responseCode', ''), - result.get('responseMessage', '')) - - customer = Customer.get_customer(loan_data.get('customerId')) - if customer: - misisdn = customer.msisdn - else: - logger.info(f"Customer does not exist for customer id: {loan_data.get('customerId')}") - misisdn = settings.TEST_NO - - sms_data = { - "dest": misisdn, - "text": f"Transaction {loan_data.get('transactionId')} verified successfully", - "unicode": True - } - - try: - TransactionService.create_transaction(loan_data['transactionId'], loan_data['accountId'], - loan_data['customerId'], "send_sms", "USSD") - except Exception as e: - logger.info(f"Failed to LOG SMS Transaction Record: {e}") - - try: - - sms_response = requests.post(sms_url, json=sms_data, timeout=SimbrellaClient.BANK_CALL_API_TIME_OUT, - headers=get_headers()) - sms_response.raise_for_status() # Raise an exception for 4xx or 5xx status codes - - result = sms_response.json() - logger.info(f"SMS Response JSON: {result}") - if result.get('isSuccess'): - logger.info(f"sms sent successfully") - return ResponseHelper.success(response.json(), "Successful") - logger.info(f"sms failed!") - return 1 - except requests.RequestException as e: - # Handle the exception - logger.error(f"Failed to send SMS: {e}") - return 0 - except Exception as e: - logger.info(f"Failed to call TransactionVerify endpoint: {e}") - return 0 - - @staticmethod - def collect_loan_user_initiated(data): - # InitiatedBy = USER_INITIATED - try: - logger.info(f"Calling CollectLoan collect_loan_user_initiated ******* endpoint with data: {data}") - return SimbrellaClient._collect_loan(data, "1") - except Exception as e: - logger.error(f"Error in collect_loan_user_initiated: {e}") - # return ResponseHelper.error( - # message="Failed to collect loan for user initiated ", - # status_code=500, - # error=str(e) - # ) - - @staticmethod - def collect_loan_user_salary_detect(data): - try: - return SimbrellaClient._collect_loan(data, "2") - except Exception as e: - logger.error(f"Error in collect_loan_user_salary_detect: {e}") - return ResponseHelper.error( - message="Failed to collect loan for salary detection", - status_code=500, - error=str(e) - ) - - @staticmethod - def collect_loan_user_due_payment(data): - # InitiatedBy = REPAYMENT_DUE - try: - return SimbrellaClient._collect_loan(data, "3") - except Exception as e: - logger.error(f"Error in collect_loan_user_due_payment: {e}") - return ResponseHelper.error( - message="Failed to collect loan for due payment", - status_code=500, - error=str(e) - ) - - @staticmethod - def _collect_loan(data, collectionMethod: str): - api_url = f"{SimbrellaClient.BANK_CALL_BASE_URL}{SimbrellaClient.BANK_CALL_COLLECT_LOAN_ENDPOINT}" - logger.info(f"Calling CollectLoan api_url==> : {api_url}") - logger.info(f"Calling CollectLoan endpoint with data: {data}") - - repayment_data, loan, error = CollectLoanHelper._validate_repayment_and_loan(data) - if error: - return error - loan_data = loan.to_dict() - - if repayment_data['repayDate'] is not None: - logger.info(f"Repayment already processed at {repayment_data['repayDate']}") - return ResponseHelper.error("Repayment already processed") - - RepaymentService.set_repay_date(repayment_data['Id'], repayment_data['customerId']) - repayment = RepaymentService.get_repayment_by_transaction_id(transaction_id=data['transactionId']) - repayment_data = repayment.to_dict() - - collect_loan_data = CollectLoanHelper._build_collect_loan_payload(loan_data, repayment_data, data, - collectionMethod) - try: - logger.info(f"Sending CollectLoan request............ {collect_loan_data}") - response = requests.post(api_url, json=collect_loan_data, timeout=SimbrellaClient.BANK_CALL_API_TIME_OUT, - headers=get_headers()) - logger.info(f"HTTP response object: {response}") - - if response.status_code == 404: - db.session.rollback() - RepaymentService.set_repay_result( - repayment_data['Id'], - '404', - 'Collection Service url not found' - ) - if (data.get('overdueLoanScheduleId') is not None): - LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'], - 'Collection Service url not found') - - logger.error("Received 404 from external service") - return ResponseHelper.error("Collection Service URL not found", status_code=404) - - result = response.json() - logger.info(f"CollectLoan response: {result}") - - RepaymentService.set_repay_result( - repayment_data['Id'], - result.get('responseCode', ''), - result.get('responseMessage', '') - ) - - data_to_add = { - "transactionId": result.get('transactionId') or collect_loan_data.get('transactionId'), - "fbnTransactionId": loan_data['transactionId'], - "accountId": result.get('accountId') or collect_loan_data.get('accountId'), - "customerId": result.get('customerId') or collect_loan_data.get('customerId'), - "amountCollected": float(result.get('amountCollected', 0)), - "repaymentAmount": collect_loan_data.get('collectAmount'), - "responseCode": result.get('responseCode'), - "responseDescr": result.get('responseMessage'), - "balance": round(float(result.get('lienAmount', 0)), 2) - } - - new_repayment_data = RepaymentsData.add_repayment_data(data_to_add) - if new_repayment_data: - logger.info(f"Repayment data added: {new_repayment_data.to_dict()}") - else: - logger.warning("Failed to add repayment data") - updated_loan = None - response_message = result.get('responseMessage') - if result.get('responseCode') == '00': - amount_collected = Decimal(str(result.get('amountCollected', 0))).quantize(Decimal('0.01'), - rounding=ROUND_HALF_UP) - logger.info(f"Amount collected: {amount_collected}") - - updated_loan = LoanService._update_loan_after_collection( - loan, loan_data, updated_loan, amount_collected, data, response_message=response_message - ) - return ResponseHelper.success(result, "Successful") - - except SSLError as ssl_err: - db.session.rollback() - logger.exception(f"SSL error while calling Simbrella endpoint: {ssl_err}") - RepaymentService.set_repay_result( - repayment_data['Id'], - '502', - 'SSL error occurred while calling Simbrella' - ) - if (data.get('overdueLoanScheduleId') is not None): - LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'], - 'SSL error occurred') - - return ResponseHelper.error("SSL handshake failed with Simbrella", status_code=502, error=str(ssl_err)) - - except (Timeout, ReadTimeout, ConnectTimeout, socket.timeout, TimeoutError) as timeout_err: - db.session.rollback() - logger.exception(f"Timeout while calling Simbrella: {timeout_err}") - RepaymentService.set_repay_result( - repayment_data['Id'], - '500', - 'There was a timeout while calling Simbrella' - ) - if (data.get('overdueLoanScheduleId') is not None): - LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'], - 'Timeout occurred') - - return ResponseHelper.error("Connection to Simbrella timed out", status_code=504, error=str(timeout_err)) - except RequestException as req_err: - db.session.rollback() - logger.exception(f"RequestException while calling Simbrella: {req_err}") - RepaymentService.set_repay_result( - repayment_data['Id'], - '500', - 'There was a request error while calling Simbrella' - ) - if (data.get('overdueLoanScheduleId') is not None): - LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'], - 'Request error occurred') - - except SystemExit as sys_exit: - db.session.rollback() - logger.error(f"SystemExit was triggered: {sys_exit}") - RepaymentService.set_repay_result( - repayment_data['Id'], - '500', - 'There was a system error while calling Simbrella' - - ) - if (data.get('overdueLoanScheduleId') is not None): - LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'], - 'Unexpected shutdown occurred') - - return ResponseHelper.error("Unexpected shutdown detected", status_code=500, error=str(sys_exit)) - - except Exception as e: - db.session.rollback() - logger.exception(f"Unexpected error occurred while calling CollectLoan: {e}") - RepaymentService.set_repay_result( - repayment_data['Id'], - '500', - 'Unexpected error while processing loan collection' - ) - if (data.get('overdueLoanScheduleId') is not None): - LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'], - 'Unexpected error occurred') - - return ResponseHelper.error("Unexpected error while processing loan collection", status_code=500, - error=str(e)) - - @staticmethod - def penal_charge(data): - - api_url = f"{SimbrellaClient.BANK_CALL_BASE_URL}/PenalCharge" - logger.info(f"Calling Penal Charge endpoint with data: {data}") - - try: - logger.info(f"Here is your Penal Charge Request data ***** : {data}") - - try: - logger.info(f"Here is your Penal Charge Request data ****** : {data}") - response = requests.post(api_url, json=data, timeout=SimbrellaClient.BANK_CALL_API_TIME_OUT, - headers=get_headers()) - logger.info(f"Penal Charge response: {response.json()}") - return ResponseHelper.success(response.json(), "Successful") - - except Exception as e: - logger.info(f"Failed to call Penal Charge endpoint: {e}") - return ResponseHelper.error("An error occurred", 500) - - except Exception as e: - logger.info(f"Failed to call Penal Charge endpoint: {e}") - raise +import requests +from app.config import settings +from app.helpers.response_helper import ResponseHelper +# from app.routes.autocall import verify_transaction +from app.models.customer import Customer +from app.services.loan import LoanService +from app.services.loan_repayment_schedule import LoanRepaymentScheduleService +from app.utils.auth import get_headers +from app.utils.extras import preprocess_loan_charges_data +import random +import string +from app.extensions import db +from app.utils.logger import logger +from flask import jsonify, current_app +from app.services.transactions import TransactionService +from app.services.repayment import RepaymentService +from app.extensions import db +from app.services.repayments_data import RepaymentsData +from app.services.salary import SalaryService +from app.enums.loan_status import LoanStatus +from app.models.loan_repayment_schedule import LoanRepaymentSchedule +from decimal import Decimal, ROUND_HALF_UP +from requests.exceptions import SSLError, RequestException, Timeout, ReadTimeout, ConnectTimeout +import sys +import socket +from app.helpers.collect_loan_helper import CollectLoanHelper + + +class SimbrellaClient: + BANK_CALL_BASE_URL = settings.BANK_CALL_BASE_URL + BANK_CALL_SMS_BASE_URL = settings.BANK_CALL_SMS_BASE_URL + BANK_CALL_DISBURSE_LOAN_ENDPOINT = settings.BANK_CALL_DISBURSE_LOAN_ENDPOINT + BANK_CALL_COLLECT_LOAN_ENDPOINT = settings.BANK_CALL_COLLECT_LOAN_ENDPOINT + BANK_CALL_TRANSACTION_VERIFY = settings.BANK_CALL_TRANSACTION_VERIFY + BANK_HEALTH_CHECK_ENDPOINT = settings.BANK_HEALTH_CHECK_ENDPOINT + BANK_CALL_API_TIME_OUT = settings.BANK_CALL_API_TIME_OUT + + @staticmethod + def disburse_loan(data): + api_url = f"{SimbrellaClient.BANK_CALL_BASE_URL}{SimbrellaClient.BANK_CALL_DISBURSE_LOAN_ENDPOINT}" + logger.info(f"Calling DisburseLoan api_url==> : {api_url}") + logger.info(f"Calling DisburseLoan endpoint with data: {data}") + + # Check if the transaction exists + logger.info(f"Checking if transaction exists") + transaction = TransactionService.get_transaction_by_transaction_id(transaction_id=data['transactionId']) + logger.info(f"Loan Response From Database ** : {transaction}") + + # If transaction is not found + if not transaction: + logger.info(f"Transaction id: {data['transactionId']}, was not found") + return 0 + + # Fetch the loan based on the transaction_id + logger.info(f"Fetching the loan with transaction ID: {data['transactionId']}") + loan = LoanService.get_loan_by_transaction_id(transaction_id=data['transactionId']) + logger.info(f"Response from database: {loan}") + + # If loan is not found + if not loan: + logger.info(f"Could not find loan with transaction id: {data['transactionId']}") + return 0 + + loan_data = loan.to_dict() + logger.info(f"Here is your loan data: {loan_data}") + + if loan_data['status'] != LoanStatus.ACTIVE: + logger.info(f"Loan with transaction id: {data['transactionId']} is not active") + return 0 + + if loan_data['disburseDate'] is not None: + logger.info("*************************SEEM LIKE A RETRY CALL -WE WILL VERIFY Result to Continue ") + logger.info( + f"Please call verify loan : {data['transactionId']} loan sent for processing at {loan_data['disburseDate']}") + # return 0 -- we need the disburseResult = '00' to be sure all is good + + if loan_data['disburseDate'] is not None and loan_data['disburseResult'] == '00': + logger.info("*************************Duplicate call to completed loan ") + logger.info( + f"Duplicate call detected for loan : {data['transactionId']} loan sent for processing at {loan_data['disburseDate']}") + return 0 + + # let us set disbursement date + LoanService.set_disbursement_date(loan_data['debtId'], + loan_data['customerId']) # toda this must return something + logger.info(f"Here is your loan data after setting disbursement date: {loan_data}") + + loan_charges = preprocess_loan_charges_data([loan_charge.to_dict() for loan_charge in loan.loan_charges]) + logger.info(f"Here are your loan_charges: {loan_charges}") + + mgt_fee = loan_charges.get("MGTFEE")['amount'] + vat_fee = loan_charges.get("VAT")['amount'] + interest_fee = loan_charges.get("INTEREST")['amount'] + insurance_fee = loan_charges.get("INSURANCE")['amount'] + product_id = str(loan_data.get('productId', "")) + debtId = str(loan_data.get('debtId', "")).strip().zfill(6) + disbursement_data = { + "transactionId": loan_data.get('transactionId'), + "fbnTransactionId": loan_data.get('transactionId'), + "debtId": debtId, + "customerId": loan_data.get('customerId'), + "accountId": loan_data.get('accountId'), + "productId": str(loan_data.get('productId', "")), + "provideAmount": loan_data.get('currentLoanAmount'), + "collectAmountInterest": interest_fee if product_id != '3MPC' else 0, + "collectAmountMgtFee": mgt_fee, + "collectAmountInsurance": insurance_fee, + "collectAmountVAT": vat_fee, + "countryId": "01", + "comment": "Loan Disbursement", + } + # ''' + # { Veryfing with the bank + # "transactionId": "string", + # "fbnTransactionId": "string", + # "debtId": "string", + # "customerId": "string", + # "accountId": "string", + # "productId": "string", + # "provideAmount": 0, + # "collectAmountInterest": 0, + # "collectAmountMgtFee": 0, + # "collectAmountInsurance": 0, + # "collectAmountVAT": 0, + # "countryId": "string", + # "comment": "string" + # } + # ''' + + try: + logger.info(f"Calling Bank Disbursement with Request data ****** : {disbursement_data}") + response = requests.post(api_url, json=disbursement_data, timeout=SimbrellaClient.BANK_CALL_API_TIME_OUT, + headers=get_headers()) + logger.info( + f"Call to bank end point returned with Here is your Disbursement Request data ****** : {disbursement_data}") + if response.status_code == 404: + logger.error("") + LoanService.set_disbursement_loan_description(loan_data['debtId'], + "Disbursement Service url not found (404)") + return ResponseHelper.error("Disbursement Service url not found (404)", status_code=404) + + logger.info(f"Disbursement response: {response.json()}") + + if response.status_code == 200: + result = response.json() + LoanService.set_disbursement_result(loan_data['debtId'], result.get('responseCode', ''), + result.get('responseMessage', '')) + reload_loan = LoanService.get_loan_by_transaction_id(transaction_id=data['transactionId']) + reload_loan_data = reload_loan.to_dict() + #mark repayment schedule as active + repayment_schedule = LoanRepaymentScheduleService.get_repayment_schedule_by_loan_id( + reload_loan_data['debtId'], include_paid=False) + logger.info(f'Loan repayment schedule: {repayment_schedule}') + if repayment_schedule: + for schedule in repayment_schedule: + logger.info(f"Updating repayment schedule ID {schedule.id} status to ACTIVE") + LoanRepaymentScheduleService.update_repayment_schedule_status_to_active(schedule.id) + + SimbrellaClient.verify_disbursement_transaction(reload_loan_data) + + return ResponseHelper.success(response.json(), "Successful") + + else: + logger.error("") + errorMessage = "Unable to complete Disbursement Service with HTTP status code: " + str( + response.status_code) + LoanService.set_disbursement_loan_description(loan_data['debtId'], errorMessage) + return ResponseHelper.error(errorMessage, status_code=response.status_code) + + except requests.exceptions.HTTPError as errh: + print(f"Disbursement HTTP Error: {errh}") + except requests.exceptions.ConnectionError as errc: + print(f"Disbursement Error Connecting: {errc}") + except requests.exceptions.Timeout as errt: + print(f"Disbursement Timeout Error: {errt}") + except requests.exceptions.RequestException as err: + print(f"Disbursement - Unexpected Error Occurred: {err}") + except Exception as e: + logger.info(f"Failed to call Disbursement endpoint: {e}") + return 0 + + # try: + # logger.info(f"Calling Bank Disbursement with Request data ****** : {disbursement_data}") + # response = requests.post(api_url, json=disbursement_data, timeout=SimbrellaClient.BANK_CALL_API_TIME_OUT, + # headers=get_headers()) + # logger.info( + # f"Call to bank end point returned with Here is your Disbursement Request data ****** : {disbursement_data}") + # if response.status_code == 404: + # logger.error("") + # LoanService.set_disbursement_loan_description(loan_data['debtId'], + # "Disbursement Service url not found (404)") + # return ResponseHelper.error("Disbursement Service url not found (404)", status_code=404) + # + # logger.info(f"Disbursement response: {response.json()}") + # + # if response.status_code == 200: + # result = response.json() + # LoanService.set_disbursement_result(loan_data['debtId'], result.get('responseCode', ''), + # result.get('responseMessage', '')) + # reload_loan = LoanService.get_loan_by_transaction_id(transaction_id=data['transactionId']) + # reload_loan_data = reload_loan.to_dict() + # SimbrellaClient.verify_disbursement_transaction(reload_loan_data) + # return ResponseHelper.success(response.json(), "Successful") + # + # else: + # logger.error("") + # errorMessage = "Unable to complete Disbursement Service with HTTP status code: " + str( + # response.status_code) + # LoanService.set_disbursement_loan_description(loan_data['debtId'], errorMessage) + # return ResponseHelper.error(errorMessage, status_code=response.status_code) + + # except Exception as e: + # logger.info(f"Failed to call Disbursement endpoint: {e}") + # return 0 + + return 1 + + @staticmethod + def verify_disbursement_transaction(loan_data): + if loan_data['disburseResult'] and loan_data['disburseResult'] == '00': + SimbrellaClient.verify_transaction(loan_data) + + @staticmethod + def verify_transaction(data): + api_url = f"{SimbrellaClient.BANK_CALL_BASE_URL}{SimbrellaClient.BANK_CALL_TRANSACTION_VERIFY}" + sms_url = f"{SimbrellaClient.BANK_CALL_SMS_BASE_URL}/singleSMS" + logger.info(f"Calling TransactionVerify api_url==> : {api_url}") + + # Check if the transaction exists + logger.info(f"Checking if transaction exists") + transaction = TransactionService.get_transaction_by_transaction_id(transaction_id=data['transactionId']) + transaction_data = transaction.to_dict() + logger.info(f"Loan Response From Database ** : {transaction}") + + # If transaction is not found + if not transaction: + logger.info(f"Transaction id: {data['transactionId']}, was not found") + return 0 + + # Fetch the loan based on the transaction_id + logger.info(f"Fetching the loan with transaction ID: {data['transactionId']}") + loan = LoanService.get_loan_by_transaction_id(transaction_id=data['transactionId']) + logger.info(f"Response from database: {loan}") + + # If loan is not found + if not loan: + logger.info(f"Could not find loan with transaction id: {data['transactionId']}") + return 0 + + loan_data = loan.to_dict() + logger.info(f"Here is your loan data: {loan_data}") + + if loan_data['disburseDate'] is not None and loan_data['disburseVerify'] is None: + LoanService.set_disburse_verify_date(loan_data['debtId'], loan_data['customerId']) + loan_data = loan.to_dict() + logger.info(f"Here is your loan data after setting verify date: {loan_data}") + logger.info(f"Good to Verify transaction id: {data['transactionId']}") + else: + logger.info( + f"Please call disburse loan : {data['transactionId']} loan send for processing first") + return 0 + + verify_data = { + "customerId": loan_data.get('customerId'), + "accountId": loan_data.get('accountId'), + "transactionId": loan_data.get('transactionId'), + "transactionType": "provide", + "fbnTransactionId": loan_data.get('transactionId'), + "countryId": "NG", + "requestId": loan_data.get('transactionId') + } + # ''' + # { Verify with bank + # "accountId": "string", + # "customerId": "string", + # "transactionId": "string", + # "fbnTransactionId": "string", + # "transactionType": "string", + # "countryId": "string", + # "requestId": "string" + # } + # ''' + try: + logger.info(f"Here is your TransactionVerify Request data ****** : {verify_data}") + response = requests.post(api_url, json=verify_data, timeout=SimbrellaClient.BANK_CALL_API_TIME_OUT, + headers=get_headers()) + if response.status_code == 404: + logger.error("Received 404 from external service") + return ResponseHelper.error("Verify Service url not found (404)", status_code=404) + result = response.json() + #check for res 00 and status 200 + logger.info(f"this is verify result, {result}") + LoanService.set_disburse_verify_result(loan_data['debtId'], result.get('responseCode', ''), + result.get('responseMessage', '')) + + customer = Customer.get_customer(loan_data.get('customerId')) + if customer: + misisdn = customer.msisdn + else: + logger.info(f"Customer does not exist for customer id: {loan_data.get('customerId')}") + misisdn = settings.TEST_NO + + sms_data = { + "dest": misisdn, + "text": f"Transaction {loan_data.get('transactionId')} verified successfully", + "unicode": True + } + + try: + TransactionService.create_transaction(loan_data['transactionId'], loan_data['accountId'], + loan_data['customerId'], "send_sms", "USSD") + except Exception as e: + logger.info(f"Failed to LOG SMS Transaction Record: {e}") + + try: + + sms_response = requests.post(sms_url, json=sms_data, timeout=SimbrellaClient.BANK_CALL_API_TIME_OUT, + headers=get_headers()) + sms_response.raise_for_status() # Raise an exception for 4xx or 5xx status codes + + result = sms_response.json() + logger.info(f"SMS Response JSON: {result}") + if result.get('isSuccess'): + logger.info(f"sms sent successfully") + return ResponseHelper.success(response.json(), "Successful") + logger.info(f"sms failed!") + return 1 + except requests.RequestException as e: + # Handle the exception + logger.error(f"Failed to send SMS: {e}") + return 0 + except Exception as e: + logger.info(f"Failed to call TransactionVerify endpoint: {e}") + return 0 + + @staticmethod + def collect_loan_user_initiated(data): + # InitiatedBy = USER_INITIATED + try: + logger.info(f"Calling CollectLoan collect_loan_user_initiated ******* endpoint with data: {data}") + return SimbrellaClient._collect_loan(data, "1") + except Exception as e: + logger.error(f"Error in collect_loan_user_initiated: {e}") + # return ResponseHelper.error( + # message="Failed to collect loan for user initiated ", + # status_code=500, + # error=str(e) + # ) + + @staticmethod + def collect_loan_user_salary_detect(data): + try: + return SimbrellaClient._collect_loan(data, "2") + except Exception as e: + logger.error(f"Error in collect_loan_user_salary_detect: {e}") + return ResponseHelper.error( + message="Failed to collect loan for salary detection", + status_code=500, + error=str(e) + ) + + @staticmethod + def collect_loan_user_due_payment(data): + # InitiatedBy = REPAYMENT_DUE + try: + return SimbrellaClient._collect_loan(data, "3") + except Exception as e: + logger.error(f"Error in collect_loan_user_due_payment: {e}") + return ResponseHelper.error( + message="Failed to collect loan for due payment", + status_code=500, + error=str(e) + ) + + @staticmethod + def _collect_loan(data, collectionMethod: str): + api_url = f"{SimbrellaClient.BANK_CALL_BASE_URL}{SimbrellaClient.BANK_CALL_COLLECT_LOAN_ENDPOINT}" + logger.info(f"Calling CollectLoan api_url==> : {api_url}") + logger.info(f"Calling CollectLoan endpoint with data: {data}") + + repayment_data, loan, error = CollectLoanHelper._validate_repayment_and_loan(data) + if error: + return error + loan_data = loan.to_dict() + + if repayment_data['repayDate'] is not None: + logger.info(f"Repayment already processed at {repayment_data['repayDate']}") + return ResponseHelper.error("Repayment already processed") + + RepaymentService.set_repay_date(repayment_data['Id'], repayment_data['customerId']) + repayment = RepaymentService.get_repayment_by_transaction_id(transaction_id=data['transactionId']) + repayment_data = repayment.to_dict() + + collect_loan_data = CollectLoanHelper._build_collect_loan_payload(loan_data, repayment_data, data, + collectionMethod) + try: + logger.info(f"Sending CollectLoan request............ {collect_loan_data}") + response = requests.post(api_url, json=collect_loan_data, timeout=SimbrellaClient.BANK_CALL_API_TIME_OUT, + headers=get_headers()) + logger.info(f"HTTP response object: {response}") + + if response.status_code == 404: + db.session.rollback() + RepaymentService.set_repay_result( + repayment_data['Id'], + '404', + 'Collection Service url not found' + ) + if (data.get('overdueLoanScheduleId') is not None): + LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'], + 'Collection Service url not found') + + logger.error("Received 404 from external service") + return ResponseHelper.error("Collection Service URL not found", status_code=404) + + result = response.json() + logger.info(f"CollectLoan response: {result}") + + RepaymentService.set_repay_result( + repayment_data['Id'], + result.get('responseCode', ''), + result.get('responseMessage', '') + ) + + data_to_add = { + "transactionId": result.get('transactionId') or collect_loan_data.get('transactionId'), + "fbnTransactionId": loan_data['transactionId'], + "accountId": result.get('accountId') or collect_loan_data.get('accountId'), + "customerId": result.get('customerId') or collect_loan_data.get('customerId'), + "amountCollected": float(result.get('amountCollected', 0)), + "repaymentAmount": collect_loan_data.get('collectAmount'), + "responseCode": result.get('responseCode'), + "responseDescr": result.get('responseMessage'), + "balance": round(float(result.get('lienAmount', 0)), 2) + } + + new_repayment_data = RepaymentsData.add_repayment_data(data_to_add) + if new_repayment_data: + logger.info(f"Repayment data added: {new_repayment_data.to_dict()}") + else: + logger.warning("Failed to add repayment data") + updated_loan = None + response_message = result.get('responseMessage') + if result.get('responseCode') == '00': + amount_collected = Decimal(str(result.get('amountCollected', 0))).quantize(Decimal('0.01'), + rounding=ROUND_HALF_UP) + logger.info(f"Amount collected: {amount_collected}") + + updated_loan = LoanService._update_loan_after_collection( + loan, loan_data, updated_loan, amount_collected, data, response_message=response_message + ) + return ResponseHelper.success(result, "Successful") + + except SSLError as ssl_err: + db.session.rollback() + logger.exception(f"SSL error while calling Simbrella endpoint: {ssl_err}") + RepaymentService.set_repay_result( + repayment_data['Id'], + '502', + 'SSL error occurred while calling Simbrella' + ) + if (data.get('overdueLoanScheduleId') is not None): + LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'], + 'SSL error occurred') + + return ResponseHelper.error("SSL handshake failed with Simbrella", status_code=502, error=str(ssl_err)) + + except (Timeout, ReadTimeout, ConnectTimeout, socket.timeout, TimeoutError) as timeout_err: + db.session.rollback() + logger.exception(f"Timeout while calling Simbrella: {timeout_err}") + RepaymentService.set_repay_result( + repayment_data['Id'], + '500', + 'There was a timeout while calling Simbrella' + ) + if (data.get('overdueLoanScheduleId') is not None): + LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'], + 'Timeout occurred') + + return ResponseHelper.error("Connection to Simbrella timed out", status_code=504, error=str(timeout_err)) + except RequestException as req_err: + db.session.rollback() + logger.exception(f"RequestException while calling Simbrella: {req_err}") + RepaymentService.set_repay_result( + repayment_data['Id'], + '500', + 'There was a request error while calling Simbrella' + ) + if (data.get('overdueLoanScheduleId') is not None): + LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'], + 'Request error occurred') + + except SystemExit as sys_exit: + db.session.rollback() + logger.error(f"SystemExit was triggered: {sys_exit}") + RepaymentService.set_repay_result( + repayment_data['Id'], + '500', + 'There was a system error while calling Simbrella' + + ) + if (data.get('overdueLoanScheduleId') is not None): + LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'], + 'Unexpected shutdown occurred') + + return ResponseHelper.error("Unexpected shutdown detected", status_code=500, error=str(sys_exit)) + + except Exception as e: + db.session.rollback() + logger.exception(f"Unexpected error occurred while calling CollectLoan: {e}") + RepaymentService.set_repay_result( + repayment_data['Id'], + '500', + 'Unexpected error while processing loan collection' + ) + if (data.get('overdueLoanScheduleId') is not None): + LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'], + 'Unexpected error occurred') + + return ResponseHelper.error("Unexpected error while processing loan collection", status_code=500, + error=str(e)) + + @staticmethod + def penal_charge(data): + + api_url = f"{SimbrellaClient.BANK_CALL_BASE_URL}/PenalCharge" + logger.info(f"Calling Penal Charge endpoint with data: {data}") + + try: + logger.info(f"Here is your Penal Charge Request data ***** : {data}") + + try: + logger.info(f"Here is your Penal Charge Request data ****** : {data}") + response = requests.post(api_url, json=data, timeout=SimbrellaClient.BANK_CALL_API_TIME_OUT, + headers=get_headers()) + logger.info(f"Penal Charge response: {response.json()}") + return ResponseHelper.success(response.json(), "Successful") + + except Exception as e: + logger.info(f"Failed to call Penal Charge endpoint: {e}") + return ResponseHelper.error("An error occurred", 500) + + except Exception as e: + logger.info(f"Failed to call Penal Charge endpoint: {e}") + raise diff --git a/app/models/__init__.py b/app/models/__init__.py index 47bc45c..739f486 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,10 +1,10 @@ -from .transactions import Transaction -from .repayment import Repayment -from .loan import Loan -from .loan_charge import LoanCharge -from .customer import Customer -from .account import Account -from .repayments_data import RepaymentsData -from .salary import Salary - +from .transactions import Transaction +from .repayment import Repayment +from .loan import Loan +from .loan_charge import LoanCharge +from .customer import Customer +from .account import Account +from .repayments_data import RepaymentsData +from .salary import Salary + __all__ = ['Transaction', 'Repayment', 'Loan', 'LoanCharge', 'Customer', 'Account', 'RepaymentsData','Salary'] \ No newline at end of file diff --git a/app/models/account.py b/app/models/account.py index 7394b3a..5c0cbd4 100644 --- a/app/models/account.py +++ b/app/models/account.py @@ -1,25 +1,25 @@ -from datetime import datetime, timezone -from sqlalchemy.orm import relationship -from app.extensions import db - - -class Account(db.Model): - __tablename__ = 'accounts' - - id = db.Column(db.String(50), primary_key=True) - customer_id = db.Column(db.String(50), nullable=False) - account_type = db.Column(db.String(50)) - status = db.Column(db.String(20), default='active') - lien_amount = db.Column(db.Float, default=0.0) - created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc)) - updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc)) - - customer = relationship( - "Customer", - primaryjoin="Customer.id == Account.customer_id", - foreign_keys=[customer_id], - back_populates="accounts", - ) - - def __repr__(self): - return f'' +from datetime import datetime, timezone +from sqlalchemy.orm import relationship +from app.extensions import db + + +class Account(db.Model): + __tablename__ = 'accounts' + + id = db.Column(db.String(50), primary_key=True) + customer_id = db.Column(db.String(50), nullable=False) + account_type = db.Column(db.String(50)) + status = db.Column(db.String(20), default='active') + lien_amount = db.Column(db.Float, default=0.0) + created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc)) + updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc)) + + customer = relationship( + "Customer", + primaryjoin="Customer.id == Account.customer_id", + foreign_keys=[customer_id], + back_populates="accounts", + ) + + def __repr__(self): + return f'' diff --git a/app/models/customer.py b/app/models/customer.py index d5f2650..a475f99 100644 --- a/app/models/customer.py +++ b/app/models/customer.py @@ -1,41 +1,41 @@ -from datetime import datetime, timezone -from sqlalchemy.orm import relationship -from app.extensions import db - - -class Customer(db.Model): - __tablename__ = 'customers' - - id = db.Column(db.String(50), primary_key=True) - msisdn = db.Column(db.String(20), unique=True, nullable=False) - country_code = db.Column(db.String(3), nullable=False) - created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc)) - updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc)) - - accounts = relationship( - "Account", - primaryjoin="Customer.id == Account.customer_id", - foreign_keys="Account.customer_id", - back_populates="customer", - ) - - loans = relationship( - "Loan", - primaryjoin="Customer.id == Loan.customer_id", - foreign_keys="Loan.customer_id", - back_populates="customer", - ) - - @classmethod - def get_customer(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'' +from datetime import datetime, timezone +from sqlalchemy.orm import relationship +from app.extensions import db + + +class Customer(db.Model): + __tablename__ = 'customers' + + id = db.Column(db.String(50), primary_key=True) + msisdn = db.Column(db.String(20), unique=True, nullable=False) + country_code = db.Column(db.String(3), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc)) + updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc)) + + accounts = relationship( + "Account", + primaryjoin="Customer.id == Account.customer_id", + foreign_keys="Account.customer_id", + back_populates="customer", + ) + + loans = relationship( + "Loan", + primaryjoin="Customer.id == Loan.customer_id", + foreign_keys="Loan.customer_id", + back_populates="customer", + ) + + @classmethod + def get_customer(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 index fae9acc..22794b9 100644 --- a/app/models/loan.py +++ b/app/models/loan.py @@ -1,415 +1,415 @@ -from datetime import datetime, timezone -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 -from app.utils.logger import logger -from app.extensions import db -from decimal import Decimal, ROUND_HALF_UP -from datetime import datetime, timezone - -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) - balance = 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) - 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) - reference = db.Column(db.String(50), nullable=True) - - total_penal_charge = db.Column(db.Float, default=0.0) - last_penal_date = db.Column(db.DateTime, nullable=True) - - customer = relationship( - "Customer", - primaryjoin="Customer.id == Loan.customer_id", - foreign_keys=[customer_id], - back_populates="loans", - ) - - loan_charges = relationship( - "LoanCharge", - primaryjoin="Loan.id == LoanCharge.loan_id", - foreign_keys="LoanCharge.loan_id", - back_populates="loan", - ) - - def __repr__(self): - return f"" - - def to_dict(self): - """ - Convert the Loan object to a dictionary format for JSON serialization. - """ - return { - 'debtId': self.id, - "customerId": self.customer_id, - 'initialLoanAmount': self.initial_loan_amount, - 'currentLoanAmount': self.current_loan_amount, - 'defaultPenaltyFee': self.default_penalty_fee, - 'continuousFee': self.continuous_fee, - 'collectionType': self.collection_type, - 'repaymentAmount':self.repayment_amount, - 'status': self.status, - 'productId': self.product_id, - 'disburseResult': self.disburse_result, - 'disburseDescription': self.disburse_description, - 'verifyResult': self.verify_result, - 'verifyDescription': self.verify_description, - 'transactionId': self.transaction_id, - 'accountId':self.account_id, - 'dueDate': self.due_date.isoformat() if self.due_date else None, - 'loanDate': self.created_at.isoformat() if self.created_at else None, - 'disburseDate': self.disburse_date.isoformat() if self.disburse_date else None, - 'disburseVerify': self.disburse_verify.isoformat() if self.disburse_verify else None, - 'reference': self.reference, - 'balance': self.balance, - 'tenor': self.tenor, - 'totalPenalCharge': self.total_penal_charge, - 'lastPenalDate': self.last_penal_date - } - - @classmethod - def get_loan_by_transaction_id(cls, transaction_id): - return cls.query.filter_by(transaction_id=transaction_id).first() - - @classmethod - def get_loan_by_loan_id(cls, loan_id): - return cls.query.filter_by(id=loan_id).first() - - @classmethod - def set_disbursement_date(cls, loan_id, customer_id): - """ - Update the disburse date 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.") - - # Check if customer_id matches - if loan.customer_id != customer_id: - raise ValueError(f"Customer ID {customer_id} does not match the loan's customer ID.") - - current_time = datetime.now() - logger.info(f"What is now ======= ==== ==> : {current_time}") - # Update loan disburse_date - loan.disburse_date = current_time - - # Commit changes to database - try: - logger.info(f"Updating disburse date for loan ID {loan_id} to {current_time}") - db.session.commit() - except Exception as e: - db.session.rollback() - logger.error(f"Failed to update disburse date: {e}") - raise - @classmethod - def set_disburse_verify_date(cls, loan_id, customer_id): - """ - Update the disburse verify date 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.") - - # Check if customer_id matches - if loan.customer_id != customer_id: - raise ValueError(f"Customer ID {customer_id} does not match the loan's customer ID.") - - current_time = datetime.now() - logger.info(f"What is now ======= ==== ==> : {current_time}") - # Update loan verify_date - loan.disburse_verify = current_time - - # Commit changes to database - try: - logger.info(f"Updating disburse verify date for loan ID {loan_id} to {current_time}") - db.session.commit() - except Exception as e: - db.session.rollback() - logger.error(f"Failed to update disburse verify date: {e}") - raise - - @classmethod - def set_disbursement_message(cls, loan_id, description): - """ - Update the disburse result and description 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.") - - # Update disburse description only - loan.disburse_description = description - - # Commit changes to database - try: - logger.info(f"Updating disburse result for loan ID {loan_id} with description {description}") - db.session.commit() - except Exception as e: - db.session.rollback() - logger.error(f"Failed to update disbursement result: {e}") - raise - - - @classmethod - def set_disbursement_result(cls, loan_id, result, description): - """ - Update the disburse result and description 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.") - - # Update disburse result and description - loan.disburse_result = result - loan.disburse_description = description - - # Commit changes to database - try: - logger.info(f"Updating disburse result for loan ID {loan_id} to {result} with description {description}") - db.session.commit() - except Exception as e: - db.session.rollback() - logger.error(f"Failed to update disbursement result: {e}") - raise - - - @classmethod - def set_disburse_verify_result(cls, loan_id, result, description): - """ - Update the verify result and description 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.") - - # Update disburse result and description - loan.verify_result = result - loan.verify_description = description - - # Commit changes to database - try: - logger.info(f"Updating verify result for loan ID {loan_id} to {result} with description {description}") - db.session.commit() - except Exception as e: - db.session.rollback() - logger.error(f"Failed to update verify result: {e}") - raise - @classmethod - def get_latest_loan_without_disburse_date(cls): - """ - Get the latest loan without a disbursement date. - """ - logger.info("Fetching latest loan without disburse date") - - try: - return cls.query.filter( - cls.disburse_date.is_(None) - ).order_by(cls.created_at.desc()).first() - except Exception as e: - logger.error(f"Error fetching latest loan without disburse date: {e}") - raise - - @classmethod - def get_latest_loan_with_disburse_date(cls): - """ - Get the latest loan with a disbursement date and no verification date. - """ - return cls.query.filter( - cls.disburse_date.isnot(None), - cls.disburse_verify.is_(None) - ).order_by(cls.created_at.desc()).first() - - @classmethod - def get_customer_loans(cls, customer_id): - """ - Get customer's active loans and sum by customer_id. - """ - customer_loans = cls.query.filter_by( customer_id = customer_id).all() - if not customer_loans: - raise ValueError(f"Customer with Id {customer_id} does not have any loan.") - - total_amount = ( - cls.query.with_entities(func.coalesce(func.sum(cls.balance), 0.0)) - .filter_by(customer_id=customer_id) - .scalar() - ) - - logger.info(f"Found {len(customer_loans)} loans for customer ID: {customer_id} with total amount: {total_amount}") - - return customer_loans, total_amount - @classmethod - def get_customer_active_loans(cls, customer_id): - """ - Get customer's active loans and sum by customer_id. - """ - customer_loans = cls.query.filter( - cls.customer_id == customer_id, - cls.status != 'repaid' - ).all() - - if not customer_loans: - raise ValueError(f"Customer with Id {customer_id} does not have any active loan.") - - total_amount = ( - cls.query - .with_entities(func.coalesce(func.sum(cls.balance), 0.0)) - .filter( - cls.customer_id == customer_id, - cls.status != 'repaid' - ) - .scalar() - ) - - logger.info(f"Found {len(customer_loans)} active loans for customer ID: {customer_id} with total amount: {total_amount}") - - return customer_loans, total_amount - - - @classmethod - def update_status(cls, loan_id, status): - """ - Update the status of the loan record with the given loan_id. - """ - try: - # Retrieve loan record - 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 loan.to_dict() # Still return the current state if no change - - # Update status and timestamp - loan.status = status - loan.updated_at = datetime.now(timezone.utc) - db.session.commit() - - logger.info("Loan status updated and committed.") - return loan.to_dict() - - except Exception as e: - db.session.rollback() - logger.error(f"Error updating loan status: {e}") - raise Exception(f"Error updating loan status: {str(e)}") - - - @classmethod - def update_loan_balance(cls, loan_id, amount_collected): - """ - Update the balance of a loan after successful repayment. - """ - try: - # Fetch the loan record - loan = cls.query.get(loan_id) - - if not loan: - raise ValueError(f"Loan with ID {loan_id} does not exist.") - - # Convert to Decimal and round to 2 decimal places - amount_collected = Decimal(str(amount_collected)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - balance = Decimal(str(loan.balance or 0)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - - # Ensure valid repayment amount - if amount_collected <= Decimal("0.00"): - logger.info(f"Repayment amount is less than or equal to 0: {amount_collected}. Must be greater than 0.00") - if balance <= Decimal("0.00"): - raise ValueError("There is no balance for this loan.") - if amount_collected > balance: - # allow tiny rounding diff - if abs(amount_collected - balance) <= Decimal("0.01"): - amount_collected = balance - else: - raise ValueError("Repayment amount exceeds current loan balance.") - - - # Deduct the amount from the current balance - new_balance = balance - amount_collected - loan.balance = float(new_balance) - loan.updated_at = datetime.now(timezone.utc) - db.session.commit() - - logger.info(f"Loan balance updated for loan ID {loan_id}. New balance: {loan.balance}") - return loan.to_dict() - - except Exception as e: - db.session.rollback() - logger.error(f"Error updating loan balance: {e}") - raise Exception(f"Error updating loan balance: {str(e)}") - - @classmethod - def get_overdue_loans(cls): - """ - Get all overdue loans. - """ - try: - overdue_loans = cls.query.filter( - cls.due_date < datetime.now(timezone.utc), - cls.status != 'repaid' - ).all() - - if not overdue_loans: - logger.info("No overdue loans found.") - return [] - - logger.info(f"Found {len(overdue_loans)} overdue loans.") - return overdue_loans - except Exception as e: - logger.error(f"Error fetching overdue loans: {e}") - return [] - - @classmethod - def apply_penal_to_loan(cls, loan_id, penal_amount): - - loan = cls.query.get(loan_id) - - if not loan: - raise ValueError("Loan not found") - penal_amount = Decimal(str(penal_amount)) - - loan.total_penal_charge = Decimal(str(loan.total_penal_charge or 0)) + penal_amount - loan.last_penal_date = datetime.now(timezone.utc) - - db.session.commit() +from datetime import datetime, timezone +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 +from app.utils.logger import logger +from app.extensions import db +from decimal import Decimal, ROUND_HALF_UP +from datetime import datetime, timezone + +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) + balance = 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) + 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) + reference = db.Column(db.String(50), nullable=True) + + total_penal_charge = db.Column(db.Float, default=0.0) + last_penal_date = db.Column(db.DateTime, nullable=True) + + customer = relationship( + "Customer", + primaryjoin="Customer.id == Loan.customer_id", + foreign_keys=[customer_id], + back_populates="loans", + ) + + loan_charges = relationship( + "LoanCharge", + primaryjoin="Loan.id == LoanCharge.loan_id", + foreign_keys="LoanCharge.loan_id", + back_populates="loan", + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """ + Convert the Loan object to a dictionary format for JSON serialization. + """ + return { + 'debtId': self.id, + "customerId": self.customer_id, + 'initialLoanAmount': self.initial_loan_amount, + 'currentLoanAmount': self.current_loan_amount, + 'defaultPenaltyFee': self.default_penalty_fee, + 'continuousFee': self.continuous_fee, + 'collectionType': self.collection_type, + 'repaymentAmount':self.repayment_amount, + 'status': self.status, + 'productId': self.product_id, + 'disburseResult': self.disburse_result, + 'disburseDescription': self.disburse_description, + 'verifyResult': self.verify_result, + 'verifyDescription': self.verify_description, + 'transactionId': self.transaction_id, + 'accountId':self.account_id, + 'dueDate': self.due_date.isoformat() if self.due_date else None, + 'loanDate': self.created_at.isoformat() if self.created_at else None, + 'disburseDate': self.disburse_date.isoformat() if self.disburse_date else None, + 'disburseVerify': self.disburse_verify.isoformat() if self.disburse_verify else None, + 'reference': self.reference, + 'balance': self.balance, + 'tenor': self.tenor, + 'totalPenalCharge': self.total_penal_charge, + 'lastPenalDate': self.last_penal_date + } + + @classmethod + def get_loan_by_transaction_id(cls, transaction_id): + return cls.query.filter_by(transaction_id=transaction_id).first() + + @classmethod + def get_loan_by_loan_id(cls, loan_id): + return cls.query.filter_by(id=loan_id).first() + + @classmethod + def set_disbursement_date(cls, loan_id, customer_id): + """ + Update the disburse date 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.") + + # Check if customer_id matches + if loan.customer_id != customer_id: + raise ValueError(f"Customer ID {customer_id} does not match the loan's customer ID.") + + current_time = datetime.now() + logger.info(f"What is now ======= ==== ==> : {current_time}") + # Update loan disburse_date + loan.disburse_date = current_time + + # Commit changes to database + try: + logger.info(f"Updating disburse date for loan ID {loan_id} to {current_time}") + db.session.commit() + except Exception as e: + db.session.rollback() + logger.error(f"Failed to update disburse date: {e}") + raise + @classmethod + def set_disburse_verify_date(cls, loan_id, customer_id): + """ + Update the disburse verify date 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.") + + # Check if customer_id matches + if loan.customer_id != customer_id: + raise ValueError(f"Customer ID {customer_id} does not match the loan's customer ID.") + + current_time = datetime.now() + logger.info(f"What is now ======= ==== ==> : {current_time}") + # Update loan verify_date + loan.disburse_verify = current_time + + # Commit changes to database + try: + logger.info(f"Updating disburse verify date for loan ID {loan_id} to {current_time}") + db.session.commit() + except Exception as e: + db.session.rollback() + logger.error(f"Failed to update disburse verify date: {e}") + raise + + @classmethod + def set_disbursement_message(cls, loan_id, description): + """ + Update the disburse result and description 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.") + + # Update disburse description only + loan.disburse_description = description + + # Commit changes to database + try: + logger.info(f"Updating disburse result for loan ID {loan_id} with description {description}") + db.session.commit() + except Exception as e: + db.session.rollback() + logger.error(f"Failed to update disbursement result: {e}") + raise + + + @classmethod + def set_disbursement_result(cls, loan_id, result, description): + """ + Update the disburse result and description 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.") + + # Update disburse result and description + loan.disburse_result = result + loan.disburse_description = description + + # Commit changes to database + try: + logger.info(f"Updating disburse result for loan ID {loan_id} to {result} with description {description}") + db.session.commit() + except Exception as e: + db.session.rollback() + logger.error(f"Failed to update disbursement result: {e}") + raise + + + @classmethod + def set_disburse_verify_result(cls, loan_id, result, description): + """ + Update the verify result and description 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.") + + # Update disburse result and description + loan.verify_result = result + loan.verify_description = description + + # Commit changes to database + try: + logger.info(f"Updating verify result for loan ID {loan_id} to {result} with description {description}") + db.session.commit() + except Exception as e: + db.session.rollback() + logger.error(f"Failed to update verify result: {e}") + raise + @classmethod + def get_latest_loan_without_disburse_date(cls): + """ + Get the latest loan without a disbursement date. + """ + logger.info("Fetching latest loan without disburse date") + + try: + return cls.query.filter( + cls.disburse_date.is_(None) + ).order_by(cls.created_at.desc()).first() + except Exception as e: + logger.error(f"Error fetching latest loan without disburse date: {e}") + raise + + @classmethod + def get_latest_loan_with_disburse_date(cls): + """ + Get the latest loan with a disbursement date and no verification date. + """ + return cls.query.filter( + cls.disburse_date.isnot(None), + cls.disburse_verify.is_(None) + ).order_by(cls.created_at.desc()).first() + + @classmethod + def get_customer_loans(cls, customer_id): + """ + Get customer's active loans and sum by customer_id. + """ + customer_loans = cls.query.filter_by( customer_id = customer_id).all() + if not customer_loans: + raise ValueError(f"Customer with Id {customer_id} does not have any loan.") + + total_amount = ( + cls.query.with_entities(func.coalesce(func.sum(cls.balance), 0.0)) + .filter_by(customer_id=customer_id) + .scalar() + ) + + logger.info(f"Found {len(customer_loans)} loans for customer ID: {customer_id} with total amount: {total_amount}") + + return customer_loans, total_amount + @classmethod + def get_customer_active_loans(cls, customer_id): + """ + Get customer's active loans and sum by customer_id. + """ + customer_loans = cls.query.filter( + cls.customer_id == customer_id, + cls.status != 'repaid' + ).all() + + if not customer_loans: + raise ValueError(f"Customer with Id {customer_id} does not have any active loan.") + + total_amount = ( + cls.query + .with_entities(func.coalesce(func.sum(cls.balance), 0.0)) + .filter( + cls.customer_id == customer_id, + cls.status != 'repaid' + ) + .scalar() + ) + + logger.info(f"Found {len(customer_loans)} active loans for customer ID: {customer_id} with total amount: {total_amount}") + + return customer_loans, total_amount + + + @classmethod + def update_status(cls, loan_id, status): + """ + Update the status of the loan record with the given loan_id. + """ + try: + # Retrieve loan record + 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 loan.to_dict() # Still return the current state if no change + + # Update status and timestamp + loan.status = status + loan.updated_at = datetime.now(timezone.utc) + db.session.commit() + + logger.info("Loan status updated and committed.") + return loan.to_dict() + + except Exception as e: + db.session.rollback() + logger.error(f"Error updating loan status: {e}") + raise Exception(f"Error updating loan status: {str(e)}") + + + @classmethod + def update_loan_balance(cls, loan_id, amount_collected): + """ + Update the balance of a loan after successful repayment. + """ + try: + # Fetch the loan record + loan = cls.query.get(loan_id) + + if not loan: + raise ValueError(f"Loan with ID {loan_id} does not exist.") + + # Convert to Decimal and round to 2 decimal places + amount_collected = Decimal(str(amount_collected)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + balance = Decimal(str(loan.balance or 0)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + # Ensure valid repayment amount + if amount_collected <= Decimal("0.00"): + logger.info(f"Repayment amount is less than or equal to 0: {amount_collected}. Must be greater than 0.00") + if balance <= Decimal("0.00"): + raise ValueError("There is no balance for this loan.") + if amount_collected > balance: + # allow tiny rounding diff + if abs(amount_collected - balance) <= Decimal("0.01"): + amount_collected = balance + else: + raise ValueError("Repayment amount exceeds current loan balance.") + + + # Deduct the amount from the current balance + new_balance = balance - amount_collected + loan.balance = float(new_balance) + loan.updated_at = datetime.now(timezone.utc) + db.session.commit() + + logger.info(f"Loan balance updated for loan ID {loan_id}. New balance: {loan.balance}") + return loan.to_dict() + + except Exception as e: + db.session.rollback() + logger.error(f"Error updating loan balance: {e}") + raise Exception(f"Error updating loan balance: {str(e)}") + + @classmethod + def get_overdue_loans(cls): + """ + Get all overdue loans. + """ + try: + overdue_loans = cls.query.filter( + cls.due_date < datetime.now(timezone.utc), + cls.status != 'repaid' + ).all() + + if not overdue_loans: + logger.info("No overdue loans found.") + return [] + + logger.info(f"Found {len(overdue_loans)} overdue loans.") + return overdue_loans + except Exception as e: + logger.error(f"Error fetching overdue loans: {e}") + return [] + + @classmethod + def apply_penal_to_loan(cls, loan_id, penal_amount): + + loan = cls.query.get(loan_id) + + if not loan: + raise ValueError("Loan not found") + penal_amount = Decimal(str(penal_amount)) + + loan.total_penal_charge = Decimal(str(loan.total_penal_charge or 0)) + penal_amount + loan.last_penal_date = datetime.now(timezone.utc) + + db.session.commit() diff --git a/app/models/loan_charge.py b/app/models/loan_charge.py index 9684103..8573fa8 100644 --- a/app/models/loan_charge.py +++ b/app/models/loan_charge.py @@ -1,127 +1,127 @@ -from datetime import datetime, timezone, timedelta -from os.path import devnull -from sqlalchemy.exc import IntegrityError -from app.extensions import db -from sqlalchemy.orm import relationship - -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, default=datetime.now(timezone.utc)) - updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc)) - - loan = relationship( - "Loan", - primaryjoin="LoanCharge.loan_id == Loan.id", - foreign_keys=[loan_id], - back_populates="loan_charges", - ) - - def __repr__(self): - return f"" - - def to_dict(self): - """ - Convert the Loan charge object to a dictionary format for JSON serialization. - """ - 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 - } - #get last penal - @classmethod - def get_last_penal_no(cls, loan_id): - """ - Returns the last penal number created for a loan. - Example: - PENAL1 -> returns 1 - PENAL3 -> returns 3 - If none exists, returns 0. - """ - last_penal = ( - cls.query - .filter(cls.loan_id == loan_id) - .filter(cls.code.like("PENAL%")) - .order_by(cls.id.desc()) - .first() - ) - - if not last_penal: - return 0 - - try: - return int(last_penal.code.replace("PENAL", "")) - except ValueError: - return 0 - @classmethod - def get_penal_charges_by_loan_id(cls, loan_id): - """ - Returns all penal charges for a specific loan. - """ - return cls.query.filter( - cls.loan_id == loan_id, - cls.code.like("PENAL%") - ).all() - - - @classmethod - def get_loan_charge_by_debt_id(cls, debt_id): - return cls.query.filter_by(loan_id=debt_id) - - #create penal charge - @classmethod - def create_penal_charges_for_loan(cls, loan_id, transaction_id, percent, penal_no, schedule_number, penal_amount=0.0): - """ - Create a penal charge for a given loan and schedule. - """ - - if loan_id is None: - raise ValueError("loan_id cannot be None") - - code = f"PENAL{penal_no:02d}-SCHEDULE{schedule_number:02d}" - - # Check if this penal charge already exists - existing = cls.query.filter_by( - loan_id=loan_id, - code=code - ).first() - - if existing: - return existing - - now = datetime.now(timezone.utc) - - penal_charge = cls( - loan_id=loan_id, - transaction_id=transaction_id, - code=code, - amount=penal_amount, - percent=percent, - description=f"Penal Charge {penal_no} for loan {loan_id} schedule {schedule_number}", - due=True, - due_date=now - ) - - try: - db.session.add(penal_charge) - db.session.commit() - except IntegrityError as err: - db.session.rollback() - raise ValueError(f"Database integrity error: {err}") - +from datetime import datetime, timezone, timedelta +from os.path import devnull +from sqlalchemy.exc import IntegrityError +from app.extensions import db +from sqlalchemy.orm import relationship + +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, default=datetime.now(timezone.utc)) + updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc)) + + loan = relationship( + "Loan", + primaryjoin="LoanCharge.loan_id == Loan.id", + foreign_keys=[loan_id], + back_populates="loan_charges", + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """ + Convert the Loan charge object to a dictionary format for JSON serialization. + """ + 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 + } + #get last penal + @classmethod + def get_last_penal_no(cls, loan_id): + """ + Returns the last penal number created for a loan. + Example: + PENAL1 -> returns 1 + PENAL3 -> returns 3 + If none exists, returns 0. + """ + last_penal = ( + cls.query + .filter(cls.loan_id == loan_id) + .filter(cls.code.like("PENAL%")) + .order_by(cls.id.desc()) + .first() + ) + + if not last_penal: + return 0 + + try: + return int(last_penal.code.replace("PENAL", "")) + except ValueError: + return 0 + @classmethod + def get_penal_charges_by_loan_id(cls, loan_id): + """ + Returns all penal charges for a specific loan. + """ + return cls.query.filter( + cls.loan_id == loan_id, + cls.code.like("PENAL%") + ).all() + + + @classmethod + def get_loan_charge_by_debt_id(cls, debt_id): + return cls.query.filter_by(loan_id=debt_id) + + #create penal charge + @classmethod + def create_penal_charges_for_loan(cls, loan_id, transaction_id, percent, penal_no, schedule_number, penal_amount=0.0): + """ + Create a penal charge for a given loan and schedule. + """ + + if loan_id is None: + raise ValueError("loan_id cannot be None") + + code = f"PENAL{penal_no:02d}-SCHEDULE{schedule_number:02d}" + + # Check if this penal charge already exists + existing = cls.query.filter_by( + loan_id=loan_id, + code=code + ).first() + + if existing: + return existing + + now = datetime.now(timezone.utc) + + penal_charge = cls( + loan_id=loan_id, + transaction_id=transaction_id, + code=code, + amount=penal_amount, + percent=percent, + description=f"Penal Charge {penal_no} for loan {loan_id} schedule {schedule_number}", + due=True, + due_date=now + ) + + try: + db.session.add(penal_charge) + db.session.commit() + except IntegrityError as err: + db.session.rollback() + raise ValueError(f"Database integrity error: {err}") + return penal_charge \ No newline at end of file diff --git a/app/models/loan_repayment_schedule.py b/app/models/loan_repayment_schedule.py index dcba841..17800ce 100644 --- a/app/models/loan_repayment_schedule.py +++ b/app/models/loan_repayment_schedule.py @@ -1,336 +1,347 @@ -from datetime import datetime, timedelta, timezone -from app.extensions import db -from app.utils.logger import logger -from sqlalchemy.exc import SQLAlchemyError -from app.enums.repayment_schedule_status import RepaymentScheduleStatus -from app.config import settings - -from decimal import Decimal, ROUND_HALF_UP -# from dateutil.relativedelta import relativedelta - -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) - due_process_date = db.Column(db.DateTime, nullable=True) - due_process_count = db.Column(db.Integer, default=0) - paid_status = db.Column(db.String(20), nullable=True) - repay_description = db.Column(db.String(255), nullable=True) - partial_balance = db.Column(db.Float, default=0.0) - created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc)) - updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc)) - penal_charge = db.Column(db.Float, default=0.0) - penal_count = db.Column(db.Integer, default=0) - last_penal_date = db.Column(db.DateTime, nullable=True) - - - - def to_dict(self): - return { - 'id': self.id, - 'loan_id': self.loan_id, - 'product_id': self.product_id, - 'transaction_id': self.transaction_id, - 'installment_number': self.installment_number, - 'due_date': self.due_date.isoformat() if self.due_date else None, - 'installment_amount': self.installment_amount, - 'total_repayment_amount': self.total_repayment_amount, - 'paid': self.paid, - 'due_process_date': self.due_process_date.isoformat() if self.due_process_date else None, - 'due_process_count': self.due_process_count, - 'paid_status': self.paid_status, - 'repay_description': self.repay_description, - 'partial_balance': self.partial_balance, - 'paid_at': self.paid_at.isoformat() if self.paid_at 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, - 'penal_charge': self.penal_charge, - 'penal_count': self.penal_count, - 'last_penal_date': self.last_penal_date.isoformat() if self.last_penal_date else None - - } - - def __repr__(self): - return f'' - - @classmethod - def get_repayment_schedule_by_loan_id(cls, loan_id, include_paid=True): - """ - Get repayment schedules by loan ID. - - :param loan_id: Loan ID to filter by - :param include_paid: If True, include all schedules. If False, only unpaid ones. - :return: List of repayment schedules ordered by due_date - """ - try: - query = cls.query.filter_by(loan_id=loan_id) - if not include_paid: - query = query.filter_by(paid=False) - - schedules = query.order_by(cls.due_date.asc()).all() - return schedules - - except Exception as e: - logger.error(f"Error fetching repayment schedules for loan {loan_id}: {e}") - raise - - @classmethod - def get_repayment_schedule_by_id_and_transaction_id(cls, id, transaction_id): - """ - Get repayment schedule by ID and transaction ID - """ - try: - return cls.query.filter_by(id=id, transaction_id=transaction_id).first() - except Exception as e: - logger.error(f"Error fetching repayment schedule for id={id}, transaction_id={transaction_id}: {e}") - return None - - @classmethod - def get_overdue_repayment_schedule(cls): - """ - Get all overdue repayment schedules that are not repaid. - """ - try: - return cls.query.filter(cls.due_date < datetime.now(timezone.utc), cls.paid == False).order_by(cls.due_date.asc()).all() - except Exception as e: - logger.error(f"Error fetching overdue repayment schedules: {e}") - return [] - @classmethod - def get_active_overdue_repayment_schedule(cls): - """ - Get all overdue repayment schedules that are active. - """ - try: - return ( - cls.query - .filter( - cls.due_date < datetime.now(timezone.utc), - cls.paid_status == RepaymentScheduleStatus.ACTIVE - ) - .order_by(cls.due_date.asc()) - .all() - ) - except Exception as e: - logger.error(f"Error fetching active overdue repayment schedules: {e}") - return [] - @classmethod - def get_overdue_repayment_schedule_with_grace_period(cls, grace_period_days, limit=None): - """ - Get all overdue repayment schedules that are not repaid and beyond the grace period. - """ - try: - grace_period_date = datetime.now(timezone.utc) - timedelta(days=grace_period_days) - return cls.query.filter( - cls.due_date < grace_period_date, - cls.paid == False - ).order_by(cls.due_date.asc()).limit(limit).all() - except Exception as e: - logger.error(f"Error fetching overdue repayment schedules with grace period: {e}") - return [] - - @classmethod - def get_partially_paid_overdue_repayment_schedule(cls): - """ - Get all overdue repayment schedules that are partially paid. - """ - try: - return ( - cls.query - .filter( - cls.due_date < datetime.now(timezone.utc), - cls.paid_status == RepaymentScheduleStatus.PARTIALLY_PAID - ) - .order_by(cls.due_date.asc()) - .all() - ) - except Exception as e: - logger.error(f"Error fetching partially paid overdue repayment schedules: {e}") - return [] - - - @classmethod - def get_repayment_schedule_by_transaction_id(cls, transaction_id): - """ - Get repayment schedule by transaction ID - """ - return cls.query.filter_by(transaction_id=transaction_id).all() - @classmethod - def update_repayment_schedule_description(cls, schedule_id, description): - """ - Update the repayment description for a specific schedule. - """ - try: - schedule = cls.query.get(schedule_id) - if not schedule: - raise ValueError(f"Schedule with ID {schedule_id} does not exist.") - - schedule.repay_description = description - schedule.updated_at = datetime.now(timezone.utc) - - db.session.commit() - logger.info(f"Updated repayment description for schedule ID {schedule_id}") - - return schedule.to_dict() - - except Exception as e: - db.session.rollback() - logger.error(f"Error updating repayment description for schedule {schedule_id}: {e}") - raise - @classmethod - def update_repayment_schedule_status(cls, schedule_id): - """ - Mark a repayment schedule as fully repaid when the parent loan is fully repaid. - This function does not take amount_collected because the loan is already cleared. - """ - try: - # Fetch schedule - schedule = cls.query.get(schedule_id) - if not schedule: - raise ValueError(f"Schedule with ID {schedule_id} does not exist.") - - # Force balance to 0 - schedule.partial_balance = 0.0 - schedule.paid_status = RepaymentScheduleStatus.REPAID - schedule.paid = True - schedule.paid_at = datetime.now(timezone.utc) - - # Track due processing - if schedule.due_process_count is None: - schedule.due_process_count = 0 - schedule.due_process_count += 1 - schedule.due_process_date = datetime.now(timezone.utc) - - # Update timestamp - schedule.updated_at = datetime.now(timezone.utc) - - # Commit changes - db.session.commit() - logger.info(f"Schedule {schedule_id} marked as REPAID since parent loan is fully repaid.") - - return schedule.to_dict() - - except Exception as e: - db.session.rollback() - logger.error(f"Error updating repayment schedule {schedule_id} after loan repayment: {e}") - raise - - @classmethod - def update_repayment_schedule_status_to_active(cls, schedule_id): - """ - Update repayment schedule status to ACTIVE. - """ - try: - schedule = cls.query.get(schedule_id) - if not schedule: - raise ValueError(f"Schedule with ID {schedule_id} does not exist.") - schedule.paid_status = RepaymentScheduleStatus.ACTIVE - schedule.updated_at = datetime.now(timezone.utc) - db.session.commit() - logger.info(f"Updated repayment schedule ID {schedule_id} status to ACTIVE") - return schedule.to_dict() - except Exception as e: - db.session.rollback() - logger.error(f"Error updating repayment schedule status for schedule {schedule_id}: {e}") - raise - - @classmethod - def update_repayment_schedule_balance(cls, schedule_id, amount_collected): - """ - Apply repayment to a loan schedule: - - Deduct from partial balance if partially paid. - - Otherwise deduct from installment amount. - - Update partial balance, paid status, timestamps, etc. - """ - try: - schedule = cls.query.get(schedule_id) - if not schedule: - raise ValueError(f"Schedule with ID {schedule_id} does not exist.") - - # Normalize amount - amount_collected = Decimal(str(amount_collected)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - if amount_collected <= Decimal("0.00"): - logger.info("Repayment amount must be greater than zero.") - return schedule.to_dict() - - # Determine current balance - if schedule.paid_status == RepaymentScheduleStatus.PARTIALLY_PAID and (schedule.partial_balance or 0) > 0: - balance = Decimal(str(schedule.partial_balance)) - else: - balance = Decimal(str(schedule.installment_amount)) - - # Deduct repayment - new_balance = balance - amount_collected - if new_balance < 0: - new_balance = Decimal("0.00") # prevent negatives - - # Update schedule fields - schedule.partial_balance = float(new_balance) if new_balance > 0 else 0.0 - schedule.updated_at = datetime.now(timezone.utc) - - if new_balance == 0: - schedule.paid_status = RepaymentScheduleStatus.REPAID - schedule.paid = True - schedule.paid_at = datetime.now(timezone.utc) - else: - schedule.paid_status = RepaymentScheduleStatus.PARTIALLY_PAID - schedule.paid = False # not fully paid yet - - # Track due processing - if schedule.due_process_count is None: - schedule.due_process_count = 0 - schedule.due_process_count += 1 - schedule.due_process_date = datetime.now(timezone.utc) - - # Commit - db.session.commit() - logger.info(f"Repayment applied for schedule ID {schedule_id}. Remaining balance: {schedule.partial_balance}") - - return schedule.to_dict() - - except Exception as e: - db.session.rollback() - logger.error(f"Error applying repayment for schedule {schedule_id}: {e}") - raise - @classmethod - def apply_penal_to_schedule(cls, schedule_id, penal_amount): - - schedule = cls.query.get(schedule_id) - - now = datetime.now(timezone.utc) - - schedule.penal_count = (schedule.penal_count or 0) + 1 - schedule.penal_charge = (schedule.penal_charge or 0) + penal_amount - schedule.last_penal_date = now - schedule.due_process_date = now - schedule.updated_at = now - - db.session.commit() - - - @classmethod - def calculate_penal_charge(cls, schedule): - - if schedule.paid_status == RepaymentScheduleStatus.PARTIALLY_PAID: - outstanding = Decimal(str(schedule.partial_balance)) - else: - outstanding = Decimal(str(schedule.installment_amount)) - - rate = Decimal(str(settings.PENAL_CHARGE_PERCENTAGE)) / 100 - - penal_charge = (outstanding * rate).quantize( - Decimal("0.01"), - rounding=ROUND_HALF_UP - ) - - return penal_charge - +from datetime import datetime, timedelta, timezone +from app.extensions import db +from app.utils.logger import logger +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy import or_ +from app.enums.repayment_schedule_status import RepaymentScheduleStatus +from app.config import settings + +from decimal import Decimal, ROUND_HALF_UP +# from dateutil.relativedelta import relativedelta + +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) + due_process_date = db.Column(db.DateTime, nullable=True) + due_process_count = db.Column(db.Integer, default=0) + paid_status = db.Column(db.String(20), nullable=True) + repay_description = db.Column(db.String(255), nullable=True) + partial_balance = db.Column(db.Float, default=0.0) + created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc)) + updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc)) + penal_charge = db.Column(db.Float, default=0.0) + penal_count = db.Column(db.Integer, default=0) + last_penal_date = db.Column(db.DateTime, nullable=True) + + + + def to_dict(self): + return { + 'id': self.id, + 'loan_id': self.loan_id, + 'product_id': self.product_id, + 'transaction_id': self.transaction_id, + 'installment_number': self.installment_number, + 'due_date': self.due_date.isoformat() if self.due_date else None, + 'installment_amount': self.installment_amount, + 'total_repayment_amount': self.total_repayment_amount, + 'paid': self.paid, + 'due_process_date': self.due_process_date.isoformat() if self.due_process_date else None, + 'due_process_count': self.due_process_count, + 'paid_status': self.paid_status, + 'repay_description': self.repay_description, + 'partial_balance': self.partial_balance, + 'paid_at': self.paid_at.isoformat() if self.paid_at 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, + 'penal_charge': self.penal_charge, + 'penal_count': self.penal_count, + 'last_penal_date': self.last_penal_date.isoformat() if self.last_penal_date else None + + } + + def __repr__(self): + return f'' + + @classmethod + def get_repayment_schedule_by_loan_id(cls, loan_id, include_paid=True): + """ + Get repayment schedules by loan ID. + + :param loan_id: Loan ID to filter by + :param include_paid: If True, include all schedules. If False, only unpaid ones. + :return: List of repayment schedules ordered by due_date + """ + try: + query = cls.query.filter_by(loan_id=loan_id) + if not include_paid: + query = query.filter_by(paid=False) + + schedules = query.order_by(cls.due_date.asc()).all() + return schedules + + except Exception as e: + logger.error(f"Error fetching repayment schedules for loan {loan_id}: {e}") + raise + + @classmethod + def get_repayment_schedule_by_id_and_transaction_id(cls, id, transaction_id): + """ + Get repayment schedule by ID and transaction ID + """ + try: + return cls.query.filter_by(id=id, transaction_id=transaction_id).first() + except Exception as e: + logger.error(f"Error fetching repayment schedule for id={id}, transaction_id={transaction_id}: {e}") + return None + + @classmethod + def get_overdue_repayment_schedule(cls): + """ + Get all overdue repayment schedules that are not repaid. + """ + try: + return cls.query.filter(cls.due_date < datetime.now(timezone.utc), cls.paid == False).order_by(cls.due_date.asc()).all() + except Exception as e: + logger.error(f"Error fetching overdue repayment schedules: {e}") + return [] + @classmethod + def get_active_overdue_repayment_schedule(cls): + """ + Get all overdue repayment schedules that are active. + """ + try: + return ( + cls.query + .filter( + cls.due_date < datetime.now(timezone.utc), + cls.paid_status == RepaymentScheduleStatus.ACTIVE + ) + .order_by(cls.due_date.asc()) + .all() + ) + except Exception as e: + logger.error(f"Error fetching active overdue repayment schedules: {e}") + return [] + + + @classmethod + def get_overdue_repayment_schedule_with_grace_period(cls, grace_period_days, limit=None): + try: + now = datetime.now(timezone.utc) + grace_period_date = now - timedelta(days=grace_period_days) + penal_interval = timedelta(days=settings.PENAL_CHARGE_INTERVAL_DAYS) + + return cls.query.filter( + cls.due_date < grace_period_date, + cls.paid == False, + or_( + cls.last_penal_date == None, # never penalized before + cls.last_penal_date < now - penal_interval + ) + ).order_by(cls.due_date.asc()).limit(limit).all() + + except Exception as e: + logger.error(f"Error fetching overdue repayment schedules with grace period: {e}") + return [] + + @classmethod + def get_partially_paid_overdue_repayment_schedule(cls): + """ + Get all overdue repayment schedules that are partially paid. + """ + try: + return ( + cls.query + .filter( + cls.due_date < datetime.now(timezone.utc), + cls.paid_status == RepaymentScheduleStatus.PARTIALLY_PAID + ) + .order_by(cls.due_date.asc()) + .all() + ) + except Exception as e: + logger.error(f"Error fetching partially paid overdue repayment schedules: {e}") + return [] + + + @classmethod + def get_repayment_schedule_by_transaction_id(cls, transaction_id): + """ + Get repayment schedule by transaction ID + """ + return cls.query.filter_by(transaction_id=transaction_id).all() + @classmethod + def update_repayment_schedule_description(cls, schedule_id, description): + """ + Update the repayment description for a specific schedule. + """ + try: + schedule = cls.query.get(schedule_id) + if not schedule: + raise ValueError(f"Schedule with ID {schedule_id} does not exist.") + + schedule.repay_description = description + schedule.updated_at = datetime.now(timezone.utc) + + db.session.commit() + logger.info(f"Updated repayment description for schedule ID {schedule_id}") + + return schedule.to_dict() + + except Exception as e: + db.session.rollback() + logger.error(f"Error updating repayment description for schedule {schedule_id}: {e}") + raise + @classmethod + def update_repayment_schedule_status(cls, schedule_id): + """ + Mark a repayment schedule as fully repaid when the parent loan is fully repaid. + This function does not take amount_collected because the loan is already cleared. + """ + try: + # Fetch schedule + schedule = cls.query.get(schedule_id) + if not schedule: + raise ValueError(f"Schedule with ID {schedule_id} does not exist.") + + # Force balance to 0 + schedule.partial_balance = 0.0 + schedule.paid_status = RepaymentScheduleStatus.REPAID + schedule.paid = True + schedule.paid_at = datetime.now(timezone.utc) + + # Track due processing + if schedule.due_process_count is None: + schedule.due_process_count = 0 + schedule.due_process_count += 1 + schedule.due_process_date = datetime.now(timezone.utc) + + # Update timestamp + schedule.updated_at = datetime.now(timezone.utc) + + # Commit changes + db.session.commit() + logger.info(f"Schedule {schedule_id} marked as REPAID since parent loan is fully repaid.") + + return schedule.to_dict() + + except Exception as e: + db.session.rollback() + logger.error(f"Error updating repayment schedule {schedule_id} after loan repayment: {e}") + raise + + @classmethod + def update_repayment_schedule_status_to_active(cls, schedule_id): + """ + Update repayment schedule status to ACTIVE. + """ + try: + schedule = cls.query.get(schedule_id) + if not schedule: + raise ValueError(f"Schedule with ID {schedule_id} does not exist.") + schedule.paid_status = RepaymentScheduleStatus.ACTIVE + schedule.updated_at = datetime.now(timezone.utc) + db.session.commit() + logger.info(f"Updated repayment schedule ID {schedule_id} status to ACTIVE") + return schedule.to_dict() + except Exception as e: + db.session.rollback() + logger.error(f"Error updating repayment schedule status for schedule {schedule_id}: {e}") + raise + + @classmethod + def update_repayment_schedule_balance(cls, schedule_id, amount_collected): + """ + Apply repayment to a loan schedule: + - Deduct from partial balance if partially paid. + - Otherwise deduct from installment amount. + - Update partial balance, paid status, timestamps, etc. + """ + try: + schedule = cls.query.get(schedule_id) + if not schedule: + raise ValueError(f"Schedule with ID {schedule_id} does not exist.") + + # Normalize amount + amount_collected = Decimal(str(amount_collected)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + if amount_collected <= Decimal("0.00"): + logger.info("Repayment amount must be greater than zero.") + return schedule.to_dict() + + # Determine current balance + if schedule.paid_status == RepaymentScheduleStatus.PARTIALLY_PAID and (schedule.partial_balance or 0) > 0: + balance = Decimal(str(schedule.partial_balance)) + else: + balance = Decimal(str(schedule.installment_amount)) + + # Deduct repayment + new_balance = balance - amount_collected + if new_balance < 0: + new_balance = Decimal("0.00") # prevent negatives + + # Update schedule fields + schedule.partial_balance = float(new_balance) if new_balance > 0 else 0.0 + schedule.updated_at = datetime.now(timezone.utc) + + if new_balance == 0: + schedule.paid_status = RepaymentScheduleStatus.REPAID + schedule.paid = True + schedule.paid_at = datetime.now(timezone.utc) + else: + schedule.paid_status = RepaymentScheduleStatus.PARTIALLY_PAID + schedule.paid = False # not fully paid yet + + # Track due processing + if schedule.due_process_count is None: + schedule.due_process_count = 0 + schedule.due_process_count += 1 + schedule.due_process_date = datetime.now(timezone.utc) + + # Commit + db.session.commit() + logger.info(f"Repayment applied for schedule ID {schedule_id}. Remaining balance: {schedule.partial_balance}") + + return schedule.to_dict() + + except Exception as e: + db.session.rollback() + logger.error(f"Error applying repayment for schedule {schedule_id}: {e}") + raise + from decimal import Decimal + + @classmethod + def apply_penal_to_schedule(cls, schedule_id, penal_amount): + + schedule = cls.query.get(schedule_id) + + now = datetime.now(timezone.utc) + penal_amount = Decimal(str(penal_amount)) + + current_penal = Decimal(str(schedule.penal_charge)) if schedule.penal_charge else Decimal("0") + + schedule.penal_count = (schedule.penal_count or 0) + 1 + schedule.penal_charge = current_penal + penal_amount + schedule.last_penal_date = now + schedule.due_process_date = now + schedule.updated_at = now + + db.session.commit() + @classmethod + def calculate_penal_charge(cls, schedule): + + if schedule.paid_status == RepaymentScheduleStatus.PARTIALLY_PAID: + outstanding = Decimal(str(schedule.partial_balance)) + else: + outstanding = Decimal(str(schedule.installment_amount)) + + rate = Decimal(str(settings.PENAL_CHARGE_PERCENTAGE)) / 100 + + penal_charge = (outstanding * rate).quantize( + Decimal("0.01"), + rounding=ROUND_HALF_UP + ) + + return penal_charge + \ No newline at end of file diff --git a/app/models/repayment.py b/app/models/repayment.py index 4bb2636..60a61e5 100644 --- a/app/models/repayment.py +++ b/app/models/repayment.py @@ -1,260 +1,260 @@ -from app.extensions import db -from datetime import datetime, timezone -from app.utils.logger import logger -from app.enums.loan_status import LoanStatus -from sqlalchemy.exc import IntegrityError - - -class Repayment(db.Model): - __tablename__ = "repayments" - - id = db.Column( - db.Integer, - primary_key=True, - autoincrement=True, - ) - loan_id = db.Column(db.String(50), nullable=False) - customer_id = db.Column(db.String(50), nullable=False) - product_id = db.Column(db.String(20), nullable=True) - transaction_id = db.Column(db.String(50), nullable=False) - initiated_by = db.Column(db.String(50), nullable=True) - salary_amount = db.Column(db.Float, nullable=True, default=0.0) - created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc)) - updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc)) - repay_date = db.Column(db.DateTime, nullable=True) - verify_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_result = db.Column(db.String(10), nullable=True) - verify_description = db.Column(db.String(100), nullable=True) - def __repr__(self): - return f'' - - def to_dict(self): - """ - Convert the Repayment object to a dictionary format for JSON serialization. - """ - return { - 'Id': self.id, - "customerId": self.customer_id, - 'loanId': self.loan_id, - 'productId': self.product_id, - 'repayResult': self.repay_result, - 'repayDescription': self.repay_description, - 'verifyResult': self.verify_result, - 'verifyDescription': self.verify_description, - 'transactionId': self.transaction_id, - 'initiatedBy':self.initiated_by, - 'salaryAmount':self.salary_amount, - 'repayDate': self.repay_date.isoformat() if self.repay_date else None, - 'VerifyDate': self.verify_date.isoformat() if self.verify_date else None, - } - - @classmethod - def create_repayment(cls, repayment_data): - - if repayment_data["LoanStatus"] not in [LoanStatus.ACTIVE, LoanStatus.START_REPAY,LoanStatus.ACTIVE_PARTIAL]: - raise ValueError(f"Repayment cannot be processed. Loan status: ({repayment_data['LoanStatus']})") - - repayment = cls( - customer_id=repayment_data["customerId"], - loan_id=repayment_data["loanId"], - product_id=repayment_data["productId"], - transaction_id=repayment_data["transactionId"], - created_at=datetime.now(timezone.utc), - updated_at=datetime.now(timezone.utc), - initiated_by= repayment_data["initiatedBy"], - salary_amount=repayment_data["salaryAmount"] - ) - - try: - db.session.add(repayment) - db.session.commit() - logger.info("Repayment record committed.") - return repayment - except IntegrityError as err: - logger.error(f"Database integrity error: {err}") - return {"error": "Integrity error", "details": str(err)} - - - @classmethod - def add_repayment(cls, data: dict): - """ - Create and persist a new repayment record. - """ - logger.info(f"Received repayment data: {data}") - - try: - new_repayment = cls( - loan_id=data["loanId"], - customer_id=data["customerId"], - product_id=data.get("productId"), - transaction_id=data["transactionId"], - initiated_by=data.get("initiatedBy"), - salary_amount=float(data.get("salaryAmount", 0.0)), - repay_date=( - datetime.strptime(data["repayDate"], "%Y-%m-%d") - .replace(tzinfo=timezone.utc) - if data.get("repayDate") - else None - ), - repay_result=data.get("repayResult"), - repay_description=data.get("repayDescription"), - verify_result=data.get("verifyResult"), - verify_description=data.get("verifyDescription"), - verify_date=( - datetime.strptime(data["verifyDate"], "%Y-%m-%d") - .replace(tzinfo=timezone.utc) - if data.get("verifyDate") - else None - ), - ) - - db.session.add(new_repayment) - db.session.commit() - logger.info("Repayment record committed.") - return new_repayment - - except Exception as e: - db.session.rollback() - logger.error(f"Error adding repayment data: {e}") - raise - @classmethod - def get_repayment_by_transaction_id(cls, transaction_id): - return cls.query.filter_by(transaction_id=transaction_id).first() - @classmethod - def get_repayment_by_id(cls, id): - return cls.query.filter_by(id=id).first() - - @classmethod - def set_repay_date(cls, repayment_id, customer_id): - """ - Update the repay date of the loan with the given loan_id. - """ - # Retrieve repayment - repayment = cls.query.get(repayment_id) - - if not repayment: - raise ValueError(f"repayment with ID {repayment_id} does not exist.") - - # Check if customer_id matches - if repayment.customer_id != customer_id: - raise ValueError(f"Customer ID {customer_id} does not match the repayment's customer ID.") - - current_time = datetime.now() - logger.info(f"What is now ======= ==== ==> : {current_time}") - # Update repayment date - repayment.repay_date = current_time - - # Commit changes to database - try: - logger.info(f"Updating repay date for repayment ID {repayment_id} to {current_time}") - db.session.commit() - return repayment.to_dict() - except Exception as e: - db.session.rollback() - logger.error(f"Failed to update repay date: {e}") - raise e - @classmethod - def set_repay_verify_date(cls, repayment_id, customer_id): - """ - Update the repayment verify date of the loan with the given repayment_id. - """ - # Retrieve repayment - repayment = cls.query.get(repayment_id) - - if not repayment: - raise ValueError(f"repayment with ID {repayment_id} does not exist.") - - # Check if customer_id matches - if repayment.customer_id != customer_id: - raise ValueError(f"Customer ID {customer_id} does not match the repayment's customer ID.") - - current_time = datetime.now() - logger.info(f"What is now ======= ==== ==> : {current_time}") - # Update repayment verify_date - repayment.verify_date = current_time - - # Commit changes to database - try: - logger.info(f"Updating repay verify date for repayment ID {repayment_id} to {current_time}") - db.session.commit() - except Exception as e: - db.session.rollback() - logger.error(f"Failed to update repay verify date: {e}") - raise e - - - @classmethod - def set_repay_result(cls, repayment_id, result, description): - logger.info("repay result called") - """ - Update the repayment result and description of the repayment with the given repayment_id. - """ - # Retrieve loan - repayment = cls.query.get(repayment_id) - - if not repayment: - raise ValueError(f"repayment with ID {repayment_id} does not exist.") - - # Update repayment result and description - repayment.repay_result = result - repayment.repay_description = description - - # Commit changes to database - try: - logger.info(f"Updating repayment result for repayment ID {repayment_id} to {result} with description {description}") - db.session.commit() - except Exception as e: - db.session.rollback() - logger.error(f"Failed to update repayment result: {e}") - raise - @classmethod - def set_verify_date_result(cls, repayment_id, result, description): - """ - Update the verify result and description of the repayment with the given repayment_id. - """ - # Retrieve repayment - repayment = cls.query.get(repayment_id) - - if not repayment: - raise ValueError(f"repayment with ID {repayment_id} does not exist.") - - # Update disburse result and description - repayment.verify_result = result - repayment.verify_description = description - - # Commit changes to database - try: - logger.info(f"Updating verify result for repayment ID {repayment_id} to {result} with description {description}") - db.session.commit() - except Exception as e: - db.session.rollback() - logger.error(f"Failed to update verify result: {e}") - raise - @classmethod - def get_latest_repayment_without_repay_date(cls): - """ - Get the latest repayment without a repay date. - """ - return cls.query.filter( - cls.repay_date.is_(None) - ).order_by(cls.created_at.desc()).first() - @classmethod - def get_latest_repayment_with_loanId(cls, loan_id): - """ - Get the latest repayment with loan Id. - """ - return cls.query.filter( - cls.loan_id == loan_id - ).order_by(cls.created_at.desc()).first() - - @classmethod - def get_latest_loan_with_repay_date(cls): - """ - Get the latest repayment with a repay date and no verification date. - """ - return cls.query.filter( - cls.repay_date.isnot(None), - cls.verify_date.is_(None) +from app.extensions import db +from datetime import datetime, timezone +from app.utils.logger import logger +from app.enums.loan_status import LoanStatus +from sqlalchemy.exc import IntegrityError + + +class Repayment(db.Model): + __tablename__ = "repayments" + + id = db.Column( + db.Integer, + primary_key=True, + autoincrement=True, + ) + loan_id = db.Column(db.String(50), nullable=False) + customer_id = db.Column(db.String(50), nullable=False) + product_id = db.Column(db.String(20), nullable=True) + transaction_id = db.Column(db.String(50), nullable=False) + initiated_by = db.Column(db.String(50), nullable=True) + salary_amount = db.Column(db.Float, nullable=True, default=0.0) + created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc)) + updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc)) + repay_date = db.Column(db.DateTime, nullable=True) + verify_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_result = db.Column(db.String(10), nullable=True) + verify_description = db.Column(db.String(100), nullable=True) + def __repr__(self): + return f'' + + def to_dict(self): + """ + Convert the Repayment object to a dictionary format for JSON serialization. + """ + return { + 'Id': self.id, + "customerId": self.customer_id, + 'loanId': self.loan_id, + 'productId': self.product_id, + 'repayResult': self.repay_result, + 'repayDescription': self.repay_description, + 'verifyResult': self.verify_result, + 'verifyDescription': self.verify_description, + 'transactionId': self.transaction_id, + 'initiatedBy':self.initiated_by, + 'salaryAmount':self.salary_amount, + 'repayDate': self.repay_date.isoformat() if self.repay_date else None, + 'VerifyDate': self.verify_date.isoformat() if self.verify_date else None, + } + + @classmethod + def create_repayment(cls, repayment_data): + + if repayment_data["LoanStatus"] not in [LoanStatus.ACTIVE, LoanStatus.START_REPAY,LoanStatus.ACTIVE_PARTIAL]: + raise ValueError(f"Repayment cannot be processed. Loan status: ({repayment_data['LoanStatus']})") + + repayment = cls( + customer_id=repayment_data["customerId"], + loan_id=repayment_data["loanId"], + product_id=repayment_data["productId"], + transaction_id=repayment_data["transactionId"], + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + initiated_by= repayment_data["initiatedBy"], + salary_amount=repayment_data["salaryAmount"] + ) + + try: + db.session.add(repayment) + db.session.commit() + logger.info("Repayment record committed.") + return repayment + except IntegrityError as err: + logger.error(f"Database integrity error: {err}") + return {"error": "Integrity error", "details": str(err)} + + + @classmethod + def add_repayment(cls, data: dict): + """ + Create and persist a new repayment record. + """ + logger.info(f"Received repayment data: {data}") + + try: + new_repayment = cls( + loan_id=data["loanId"], + customer_id=data["customerId"], + product_id=data.get("productId"), + transaction_id=data["transactionId"], + initiated_by=data.get("initiatedBy"), + salary_amount=float(data.get("salaryAmount", 0.0)), + repay_date=( + datetime.strptime(data["repayDate"], "%Y-%m-%d") + .replace(tzinfo=timezone.utc) + if data.get("repayDate") + else None + ), + repay_result=data.get("repayResult"), + repay_description=data.get("repayDescription"), + verify_result=data.get("verifyResult"), + verify_description=data.get("verifyDescription"), + verify_date=( + datetime.strptime(data["verifyDate"], "%Y-%m-%d") + .replace(tzinfo=timezone.utc) + if data.get("verifyDate") + else None + ), + ) + + db.session.add(new_repayment) + db.session.commit() + logger.info("Repayment record committed.") + return new_repayment + + except Exception as e: + db.session.rollback() + logger.error(f"Error adding repayment data: {e}") + raise + @classmethod + def get_repayment_by_transaction_id(cls, transaction_id): + return cls.query.filter_by(transaction_id=transaction_id).first() + @classmethod + def get_repayment_by_id(cls, id): + return cls.query.filter_by(id=id).first() + + @classmethod + def set_repay_date(cls, repayment_id, customer_id): + """ + Update the repay date of the loan with the given loan_id. + """ + # Retrieve repayment + repayment = cls.query.get(repayment_id) + + if not repayment: + raise ValueError(f"repayment with ID {repayment_id} does not exist.") + + # Check if customer_id matches + if repayment.customer_id != customer_id: + raise ValueError(f"Customer ID {customer_id} does not match the repayment's customer ID.") + + current_time = datetime.now() + logger.info(f"What is now ======= ==== ==> : {current_time}") + # Update repayment date + repayment.repay_date = current_time + + # Commit changes to database + try: + logger.info(f"Updating repay date for repayment ID {repayment_id} to {current_time}") + db.session.commit() + return repayment.to_dict() + except Exception as e: + db.session.rollback() + logger.error(f"Failed to update repay date: {e}") + raise e + @classmethod + def set_repay_verify_date(cls, repayment_id, customer_id): + """ + Update the repayment verify date of the loan with the given repayment_id. + """ + # Retrieve repayment + repayment = cls.query.get(repayment_id) + + if not repayment: + raise ValueError(f"repayment with ID {repayment_id} does not exist.") + + # Check if customer_id matches + if repayment.customer_id != customer_id: + raise ValueError(f"Customer ID {customer_id} does not match the repayment's customer ID.") + + current_time = datetime.now() + logger.info(f"What is now ======= ==== ==> : {current_time}") + # Update repayment verify_date + repayment.verify_date = current_time + + # Commit changes to database + try: + logger.info(f"Updating repay verify date for repayment ID {repayment_id} to {current_time}") + db.session.commit() + except Exception as e: + db.session.rollback() + logger.error(f"Failed to update repay verify date: {e}") + raise e + + + @classmethod + def set_repay_result(cls, repayment_id, result, description): + logger.info("repay result called") + """ + Update the repayment result and description of the repayment with the given repayment_id. + """ + # Retrieve loan + repayment = cls.query.get(repayment_id) + + if not repayment: + raise ValueError(f"repayment with ID {repayment_id} does not exist.") + + # Update repayment result and description + repayment.repay_result = result + repayment.repay_description = description + + # Commit changes to database + try: + logger.info(f"Updating repayment result for repayment ID {repayment_id} to {result} with description {description}") + db.session.commit() + except Exception as e: + db.session.rollback() + logger.error(f"Failed to update repayment result: {e}") + raise + @classmethod + def set_verify_date_result(cls, repayment_id, result, description): + """ + Update the verify result and description of the repayment with the given repayment_id. + """ + # Retrieve repayment + repayment = cls.query.get(repayment_id) + + if not repayment: + raise ValueError(f"repayment with ID {repayment_id} does not exist.") + + # Update disburse result and description + repayment.verify_result = result + repayment.verify_description = description + + # Commit changes to database + try: + logger.info(f"Updating verify result for repayment ID {repayment_id} to {result} with description {description}") + db.session.commit() + except Exception as e: + db.session.rollback() + logger.error(f"Failed to update verify result: {e}") + raise + @classmethod + def get_latest_repayment_without_repay_date(cls): + """ + Get the latest repayment without a repay date. + """ + return cls.query.filter( + cls.repay_date.is_(None) + ).order_by(cls.created_at.desc()).first() + @classmethod + def get_latest_repayment_with_loanId(cls, loan_id): + """ + Get the latest repayment with loan Id. + """ + return cls.query.filter( + cls.loan_id == loan_id + ).order_by(cls.created_at.desc()).first() + + @classmethod + def get_latest_loan_with_repay_date(cls): + """ + Get the latest repayment with a repay date and no verification date. + """ + return cls.query.filter( + cls.repay_date.isnot(None), + cls.verify_date.is_(None) ).order_by(cls.created_at.desc()).first() \ No newline at end of file diff --git a/app/models/repayments_data.py b/app/models/repayments_data.py index a61d8b8..0ee9d90 100644 --- a/app/models/repayments_data.py +++ b/app/models/repayments_data.py @@ -1,75 +1,75 @@ -from datetime import datetime, timezone -from app.extensions import db -from app.utils.logger import logger - -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) - 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) - fbn_transaction_id = db.Column(db.String(255),nullable=True) - account_id = db.Column(db.String(50), nullable=True) - customer_id = db.Column(db.String(50), nullable=True) - repayment_amount = db.Column(db.Float, nullable=True) - amount_collected = db.Column(db.Float, 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, - "added_date": self.added_date.isoformat() if self.added_date else None, - "response_code": self.response_code, - "response_descr": self.response_descr, - "customerId": self.customer_id, - "accountId": self.account_id, - "fbnTransactionId": self.fbn_transaction_id, - "repaymentAmount": self.repayment_amount, - "amountCollected": self.amount_collected, - "balance": self.balance - } - - - def __repr__(self): - return f"" - - @classmethod - def add_repayment_data(cls, data): - """ - Add a new repayment data entry. - """ - try: - repayment_amount = float(data.get('repaymentAmount', 0.0)) - amount_collected = float(data.get('amountCollected', 0.0)) - - if amount_collected < 0 or repayment_amount < 0: - raise ValueError("Amounts cannot be negative.") - - account_balance = round(repayment_amount - amount_collected, 2) - - new_data = cls( - transaction_id=data.get('transactionId'), - response_code=data.get('responseCode'), - response_descr=data.get('responseDescr'), - fbn_transaction_id=data.get('fbnTransactionId'), - account_id=data.get('accountId'), - customer_id=data.get('customerId'), - amount_collected=amount_collected, - repayment_amount=repayment_amount, - balance=account_balance, - ) - - db.session.add(new_data) - db.session.commit() - - logger.info("Repayment data committed successfully") - return new_data - - except Exception as e: - db.session.rollback() - logger.error(f"Error adding repayment data: {e}") - raise Exception(f"Error adding repayment data: {str(e)}") +from datetime import datetime, timezone +from app.extensions import db +from app.utils.logger import logger + +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) + 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) + fbn_transaction_id = db.Column(db.String(255),nullable=True) + account_id = db.Column(db.String(50), nullable=True) + customer_id = db.Column(db.String(50), nullable=True) + repayment_amount = db.Column(db.Float, nullable=True) + amount_collected = db.Column(db.Float, 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, + "added_date": self.added_date.isoformat() if self.added_date else None, + "response_code": self.response_code, + "response_descr": self.response_descr, + "customerId": self.customer_id, + "accountId": self.account_id, + "fbnTransactionId": self.fbn_transaction_id, + "repaymentAmount": self.repayment_amount, + "amountCollected": self.amount_collected, + "balance": self.balance + } + + + def __repr__(self): + return f"" + + @classmethod + def add_repayment_data(cls, data): + """ + Add a new repayment data entry. + """ + try: + repayment_amount = float(data.get('repaymentAmount', 0.0)) + amount_collected = float(data.get('amountCollected', 0.0)) + + if amount_collected < 0 or repayment_amount < 0: + raise ValueError("Amounts cannot be negative.") + + account_balance = round(repayment_amount - amount_collected, 2) + + new_data = cls( + transaction_id=data.get('transactionId'), + response_code=data.get('responseCode'), + response_descr=data.get('responseDescr'), + fbn_transaction_id=data.get('fbnTransactionId'), + account_id=data.get('accountId'), + customer_id=data.get('customerId'), + amount_collected=amount_collected, + repayment_amount=repayment_amount, + balance=account_balance, + ) + + db.session.add(new_data) + db.session.commit() + + logger.info("Repayment data committed successfully") + return new_data + + except Exception as e: + db.session.rollback() + logger.error(f"Error adding repayment data: {e}") + raise Exception(f"Error adding repayment data: {str(e)}") \ No newline at end of file diff --git a/app/models/salary.py b/app/models/salary.py index 3c6d8fc..ad880e3 100644 --- a/app/models/salary.py +++ b/app/models/salary.py @@ -1,98 +1,98 @@ -from app.extensions import db -from datetime import datetime, timezone -from app.utils.logger import logger - -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=False) - amount = db.Column(db.Float, nullable=True, default=0.0) - status = db.Column(db.String(20), nullable=True) - created_at = db.Column(db.DateTime, default=datetime.now) - updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now) - salary_date = db.Column(db.DateTime, nullable=True) - - def __repr__(self): - return f'' - - def to_dict(self): - """ - Convert the Salary object to a dictionary format for JSON serialization. - """ - return { - 'id': self.id, - 'customerId': self.customer_id, - 'accountId' : self.account_id, - 'salaryAmount': self.amount, - 'status': self.status, - 'createdAt': self.created_at.isoformat() if self.created_at else None, - 'updatedAt': self.updated_at.isoformat() if self.updated_at else None, - 'salaryDate': self.salary_date.isoformat() if self.salary_date else None, - } - - @classmethod - def add_salary_data(cls, data): - """ - Add a new salary data entry. - """ - logger.info(f"Received data:{data}") - try: - new_data = cls( - customer_id=data.get('customerId'), - amount=data.get('salaryAmount', 0.0), - status='START', - salary_date = datetime.strptime(data.get('salaryDate'), "%Y-%m-%d").date() if data.get('salaryDate') else None, - account_id=data.get('accountId') - ) - db.session.add(new_data) - db.session.commit() - logger.info("Salary data has been committed.") - return new_data - except Exception as e: - db.session.rollback() - logger.info(f"error : {str(e)}") - raise Exception(f"Error adding salary data: {str(e)}") - - @classmethod - def get_pending_salaries(cls): - """ - Retrieve all salary entries with status 'START', ordered by ID ascending. - """ - try: - return cls.query.filter_by(status='START').order_by(cls.id.asc()).all() - except Exception as e: - logger.error(f"Error fetching pending salaries: {str(e)}") - return [] - @classmethod - def update_status(cls, salary_id, status): - """ - Update the status of the salary record with the given salary_id. - """ - try: - # Retrieve salary record - salary = cls.query.get(salary_id) - - if not salary: - raise ValueError(f"Salary with ID {salary_id} does not exist.") - - if salary.status == status: - return salary.to_dict() # Still return the current state if no change - - # Update status and timestamp - salary.status = status - salary.updated_at = datetime.now(timezone.utc) # Manually update timestamp if not auto-updating - db.session.commit() - - logger.info("Salary status updated and committed.") - return salary.to_dict() - - except Exception as e: - db.session.rollback() - logger.error(f"Error updating salary status: {e}") - raise Exception(f"Error updating salary status: {str(e)}") +from app.extensions import db +from datetime import datetime, timezone +from app.utils.logger import logger + +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=False) + amount = db.Column(db.Float, nullable=True, default=0.0) + status = db.Column(db.String(20), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.now) + updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now) + salary_date = db.Column(db.DateTime, nullable=True) + + def __repr__(self): + return f'' + + def to_dict(self): + """ + Convert the Salary object to a dictionary format for JSON serialization. + """ + return { + 'id': self.id, + 'customerId': self.customer_id, + 'accountId' : self.account_id, + 'salaryAmount': self.amount, + 'status': self.status, + 'createdAt': self.created_at.isoformat() if self.created_at else None, + 'updatedAt': self.updated_at.isoformat() if self.updated_at else None, + 'salaryDate': self.salary_date.isoformat() if self.salary_date else None, + } + + @classmethod + def add_salary_data(cls, data): + """ + Add a new salary data entry. + """ + logger.info(f"Received data:{data}") + try: + new_data = cls( + customer_id=data.get('customerId'), + amount=data.get('salaryAmount', 0.0), + status='START', + salary_date = datetime.strptime(data.get('salaryDate'), "%Y-%m-%d").date() if data.get('salaryDate') else None, + account_id=data.get('accountId') + ) + db.session.add(new_data) + db.session.commit() + logger.info("Salary data has been committed.") + return new_data + except Exception as e: + db.session.rollback() + logger.info(f"error : {str(e)}") + raise Exception(f"Error adding salary data: {str(e)}") + + @classmethod + def get_pending_salaries(cls): + """ + Retrieve all salary entries with status 'START', ordered by ID ascending. + """ + try: + return cls.query.filter_by(status='START').order_by(cls.id.asc()).all() + except Exception as e: + logger.error(f"Error fetching pending salaries: {str(e)}") + return [] + @classmethod + def update_status(cls, salary_id, status): + """ + Update the status of the salary record with the given salary_id. + """ + try: + # Retrieve salary record + salary = cls.query.get(salary_id) + + if not salary: + raise ValueError(f"Salary with ID {salary_id} does not exist.") + + if salary.status == status: + return salary.to_dict() # Still return the current state if no change + + # Update status and timestamp + salary.status = status + salary.updated_at = datetime.now(timezone.utc) # Manually update timestamp if not auto-updating + db.session.commit() + + logger.info("Salary status updated and committed.") + return salary.to_dict() + + except Exception as e: + db.session.rollback() + logger.error(f"Error updating salary status: {e}") + raise Exception(f"Error updating salary status: {str(e)}") diff --git a/app/models/transactions.py b/app/models/transactions.py index ac0730e..0693c75 100644 --- a/app/models/transactions.py +++ b/app/models/transactions.py @@ -1,68 +1,68 @@ -from app.extensions import db -from datetime import datetime, timezone - -from app.utils.logger import logger -from sqlalchemy import and_, or_, not_ - -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, default=datetime.now(timezone.utc)) - updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc)) - - @classmethod - def create_transaction(cls, transaction_id, account_id, customer_id, type, channel): - logger.error(f"**Setting Transaction {transaction_id} for Type {type}") - if cls.query.filter( and_( cls.transaction_id ==transaction_id, cls.type==type) ).first(): - logger.error(f"Transaction already exists for {type}") - return '' # dont raise - do not crash beacause of this - - 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) - db.session.commit() - except IntegrityError as err: - raise ValueError(f"Database integrity error: {err}") - - return transaction - - def __repr__(self): - return f'' - - def to_dict(self): - """ - Convert the Transaction object to a dictionary format for JSON serialization. - """ - return { - 'id': self.id, - 'transaction_id': self.transaction_id, - 'account_id': self.account_id, - 'customer_id': self.customer_id, - 'phone_number':self.phone_number, - 'type': self.type, - 'channel': self.channel, - } - - @classmethod - def get_transaction_by_transaction_id(cls, transaction_id): +from app.extensions import db +from datetime import datetime, timezone + +from app.utils.logger import logger +from sqlalchemy import and_, or_, not_ + +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, default=datetime.now(timezone.utc)) + updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc)) + + @classmethod + def create_transaction(cls, transaction_id, account_id, customer_id, type, channel): + logger.error(f"**Setting Transaction {transaction_id} for Type {type}") + if cls.query.filter( and_( cls.transaction_id ==transaction_id, cls.type==type) ).first(): + logger.error(f"Transaction already exists for {type}") + return '' # dont raise - do not crash beacause of this + + 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) + db.session.commit() + except IntegrityError as err: + raise ValueError(f"Database integrity error: {err}") + + return transaction + + def __repr__(self): + return f'' + + def to_dict(self): + """ + Convert the Transaction object to a dictionary format for JSON serialization. + """ + return { + 'id': self.id, + 'transaction_id': self.transaction_id, + 'account_id': self.account_id, + 'customer_id': self.customer_id, + 'phone_number':self.phone_number, + 'type': self.type, + 'channel': self.channel, + } + + @classmethod + def get_transaction_by_transaction_id(cls, transaction_id): return cls.query.filter_by(transaction_id=transaction_id).first() \ No newline at end of file diff --git a/app/response/__init__.py b/app/response/__init__.py index 7c0277e..806f9d6 100644 --- a/app/response/__init__.py +++ b/app/response/__init__.py @@ -1,2 +1,2 @@ -from .handlers import (method_not_allowed, unsupported_media_type, +from .handlers import (method_not_allowed, unsupported_media_type, not_found, bad_request, success, created, updated) \ No newline at end of file diff --git a/app/response/handlers.py b/app/response/handlers.py index 2ce566a..40c5054 100644 --- a/app/response/handlers.py +++ b/app/response/handlers.py @@ -1,30 +1,30 @@ -from flask import jsonify -from app.helpers.response_helper import ResponseHelper - - -def method_not_allowed(error): - return ResponseHelper.method_not_allowed(message="Method Not Allowed") - - -def not_found(error): - return ResponseHelper.not_found(message="URL Not Found") - - -def bad_request(error): - return ResponseHelper.bad_request(message="Bad Request") - - -def unsupported_media_type(error): - return ResponseHelper.error(message="Unsupported Media Type", status_code=415) - - -def success(data): - return ResponseHelper.success(data=data) - - -def created(data): - return ResponseHelper.created(data=data) - - -def updated(data): - return ResponseHelper.updated(data=data) +from flask import jsonify +from app.helpers.response_helper import ResponseHelper + + +def method_not_allowed(error): + return ResponseHelper.method_not_allowed(message="Method Not Allowed") + + +def not_found(error): + return ResponseHelper.not_found(message="URL Not Found") + + +def bad_request(error): + return ResponseHelper.bad_request(message="Bad Request") + + +def unsupported_media_type(error): + return ResponseHelper.error(message="Unsupported Media Type", status_code=415) + + +def success(data): + return ResponseHelper.success(data=data) + + +def created(data): + return ResponseHelper.created(data=data) + + +def updated(data): + return ResponseHelper.updated(data=data) diff --git a/app/routes/__init__.py b/app/routes/__init__.py index 7fe2df0..eb5bd8c 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -1,2 +1,2 @@ -from .authentication import auth_bp -from .autocall import autocall_bp +from .authentication import auth_bp +from .autocall import autocall_bp diff --git a/app/routes/authentication.py b/app/routes/authentication.py index 3b364b2..6ef1ae0 100644 --- a/app/routes/authentication.py +++ b/app/routes/authentication.py @@ -1,137 +1,137 @@ -from flask import Blueprint, request, jsonify, current_app -import requests -from app.extensions import db -from sqlalchemy import text -from app.utils.auth import get_headers -from app.config import settings -from app.utils.logger import logger -from app.integrations.bank_service import BankService - -auth_bp = Blueprint("auth", __name__) - -BASE_URL = settings.BANK_CALL_BASE_URL - - -@auth_bp.route("/health", methods=["GET"]) -def health(): - logger.info("Health check endpoint called") - errors = [] # collect all errors - - try: - # Detect database type - dialect = db.engine.dialect.name.lower() - logger.info(f"Database dialect detected: {dialect}") - - # Build correct query based on DB type - if "oracle" in dialect: - query = text("SELECT 1 FROM dual") - else: - query = text("SELECT 1") - - # Test database connection - try: - db.session.execute(query) - logger.info("Database connection successful.") - except Exception as db_err: - logger.error(f"Database connection failed: {str(db_err)}") - errors.append(f"Database connection failed: {str(db_err)}") - - # Check Bank Service health - try: - bank_response = BankService.health_check() - logger.info(f"Bank Service health check response: {bank_response}") - except Exception as bank_err: - logger.error(f"Bank Service health check failed: {str(bank_err)}") - errors.append(f"Bank Service health check failed: {str(bank_err)}") - - # Build final response - if errors: - return jsonify({ - "status": "error", - "database": dialect, - "errors": errors - }), 500 - - return jsonify({ - "status": "success", - "database": dialect, - "db_status": "connected", - "bank_service_status": "operational", - "message": "All systems operational" - }), 200 - - except Exception as e: - logger.exception("Unexpected error during health check") - return jsonify({ - "status": "error", - "errors": [str(e)] - }), 500 - - - -@auth_bp.route("/login", methods=["POST"]) -def login(): - data = request.get_json() - api_url = f"{BASE_URL}/login" - - response = requests.post(api_url, json=data) - if response.status_code == 200: - return jsonify(response.json()), 200 - return jsonify({"error": "Invalid credentials"}), response.status_code - - -@auth_bp.route("/status-call", methods=["POST"]) -def status_call(): - data = request.get_json() - api_url = f"{BASE_URL}/StatusCall" - - # response = requests.post(api_url, json=data, headers=get_headers()) - # return jsonify(response.json()), response.status_code - response = { - "transactionId": "24110114545374721", - "data": { - "transactionId": "241101", - "providedAmount": 1000, - "collectedAmount": 0, - "resultCode": "00", - "resultDescription": "Loan Provision is successful", - }, - "resultCode": "00", - "resultDescription": "SUCCESS", - } - - return jsonify(response), 200 - - -@auth_bp.route("/sms", methods=["POST"]) -def sms(): - data = request.get_json() - api_url = f"{BASE_URL}/SMS" - - # response = requests.post(api_url, json=data, headers=get_headers()) - # return jsonify(response.json()), response.status_code - response = { - "data": "", - "statusCode": 200, - "IsSuccessful": True, - "errorMessage": None, - } - - return jsonify(response), 200 - - -@auth_bp.route("/bulk-sms", methods=["POST"]) -def bulk_sms(): - data = request.get_json() - api_url = f"{BASE_URL}/BulkSMS" - - # response = requests.post(api_url, json=data, headers=get_headers()) - # return jsonify(response.json()), response.status_code - response = { - "data": "", - "statusCode": 200, - "IsSuccessful": True, - "errorMessage": None, - } - - return jsonify(response), 200 +from flask import Blueprint, request, jsonify, current_app +import requests +from app.extensions import db +from sqlalchemy import text +from app.utils.auth import get_headers +from app.config import settings +from app.utils.logger import logger +from app.integrations.bank_service import BankService + +auth_bp = Blueprint("auth", __name__) + +BASE_URL = settings.BANK_CALL_BASE_URL + + +@auth_bp.route("/health", methods=["GET"]) +def health(): + logger.info("Health check endpoint called") + errors = [] # collect all errors + + try: + # Detect database type + dialect = db.engine.dialect.name.lower() + logger.info(f"Database dialect detected: {dialect}") + + # Build correct query based on DB type + if "oracle" in dialect: + query = text("SELECT 1 FROM dual") + else: + query = text("SELECT 1") + + # Test database connection + try: + db.session.execute(query) + logger.info("Database connection successful.") + except Exception as db_err: + logger.error(f"Database connection failed: {str(db_err)}") + errors.append(f"Database connection failed: {str(db_err)}") + + # Check Bank Service health + try: + bank_response = BankService.health_check() + logger.info(f"Bank Service health check response: {bank_response}") + except Exception as bank_err: + logger.error(f"Bank Service health check failed: {str(bank_err)}") + errors.append(f"Bank Service health check failed: {str(bank_err)}") + + # Build final response + if errors: + return jsonify({ + "status": "error", + "database": dialect, + "errors": errors + }), 500 + + return jsonify({ + "status": "success", + "database": dialect, + "db_status": "connected", + "bank_service_status": "operational", + "message": "All systems operational" + }), 200 + + except Exception as e: + logger.exception("Unexpected error during health check") + return jsonify({ + "status": "error", + "errors": [str(e)] + }), 500 + + + +@auth_bp.route("/login", methods=["POST"]) +def login(): + data = request.get_json() + api_url = f"{BASE_URL}/login" + + response = requests.post(api_url, json=data) + if response.status_code == 200: + return jsonify(response.json()), 200 + return jsonify({"error": "Invalid credentials"}), response.status_code + + +@auth_bp.route("/status-call", methods=["POST"]) +def status_call(): + data = request.get_json() + api_url = f"{BASE_URL}/StatusCall" + + # response = requests.post(api_url, json=data, headers=get_headers()) + # return jsonify(response.json()), response.status_code + response = { + "transactionId": "24110114545374721", + "data": { + "transactionId": "241101", + "providedAmount": 1000, + "collectedAmount": 0, + "resultCode": "00", + "resultDescription": "Loan Provision is successful", + }, + "resultCode": "00", + "resultDescription": "SUCCESS", + } + + return jsonify(response), 200 + + +@auth_bp.route("/sms", methods=["POST"]) +def sms(): + data = request.get_json() + api_url = f"{BASE_URL}/SMS" + + # response = requests.post(api_url, json=data, headers=get_headers()) + # return jsonify(response.json()), response.status_code + response = { + "data": "", + "statusCode": 200, + "IsSuccessful": True, + "errorMessage": None, + } + + return jsonify(response), 200 + + +@auth_bp.route("/bulk-sms", methods=["POST"]) +def bulk_sms(): + data = request.get_json() + api_url = f"{BASE_URL}/BulkSMS" + + # response = requests.post(api_url, json=data, headers=get_headers()) + # return jsonify(response.json()), response.status_code + response = { + "data": "", + "statusCode": 200, + "IsSuccessful": True, + "errorMessage": None, + } + + return jsonify(response), 200 diff --git a/app/routes/autocall.py b/app/routes/autocall.py index c6f669d..440687d 100644 --- a/app/routes/autocall.py +++ b/app/routes/autocall.py @@ -1,684 +1,684 @@ -import time as time_module -from flask import Blueprint, request, jsonify, current_app -import requests -from app.extensions import db -from app.config import settings -from app.helpers.response_helper import ResponseHelper -from app.helpers.collect_loan_helper import CollectLoanHelper -from app.utils.auth import get_headers -from app.utils.logger import logger -from app.integrations.simbrella import SimbrellaClient -from app.services.loan import LoanService -from app.services.repayment import RepaymentService -from app.services.salary import SalaryService -from app.services.loan_repayment_schedule import LoanRepaymentScheduleService -from app.services.loan_charge import LoanChargesService -from app.enums.loan_status import LoanStatus -from app.enums.repayment_schedule_status import RepaymentScheduleStatus -from app.utils.mail import send_report_email, get_report_data -from datetime import datetime, timezone, timedelta -from app.config import settings - -autocall_bp = Blueprint("autocall", __name__) - - -@autocall_bp.route("/refresh-verify-disbursement", methods=["GET"]) -def verify_transaction(): - logger.info(f"Calling VerifyTransaction Components") - - loan = LoanService.get_latest_loan_with_disburse_date() - if not loan: - logger.info(f"No loan found without disbursement date") - return 0 - logger.info(f"Calling VerifyTransaction endpoint with data: {loan}") - loan_data = loan.to_dict() - - data = { - "transactionId": loan_data.get('transactionId'), - "fbnTransactionId": loan_data.get('transactionId'), - "debtId": str(loan_data.get('debtId')), - "customerId": loan_data.get('customerId'), - "accountId": loan_data.get('accountId'), - "productId": str(loan_data.get('productId', "")), - "provideAmount": loan_data.get('currentLoanAmount'), - } - response = SimbrellaClient.verify_transaction(data) - return response - -@autocall_bp.route("/refresh-disbursement", methods=["GET"]) -def disbursement(): - # data = request.json() - logger.info(f"Calling Disbursement Components") - loan = LoanService.get_latest_loan_without_disburse_date() - if not loan: - logger.info(f"No loan found without disbursement date") - return 0 - logger.info(f"Calling DisburseLoan endpoint with data: {loan}") - loan_data = loan.to_dict() - - data = { - "transactionId": loan_data.get('transactionId'), - "FbnTransactionId": loan_data.get('transactionId'), - "debtId": str(loan_data.get('debtId')), - "customerId": loan_data.get('customerId'), - "accountId": loan_data.get('accountId'), - "productId": str(loan_data.get('productId', "")), - "provideAmount": loan_data.get('currentLoanAmount'), - } - response = SimbrellaClient.disburse_loan(data) - return response - -@autocall_bp.route("/retry-disbursement", methods=["POST"]) -def retry_disbursement(): - try: - data = request.get_json() - logger.info(f"Retry Transaction ID Data Received for :::: {data}") - - transactionId = data["transactionId"] - logger.info(f"Starting Transaction ID Data Received for :::: {transactionId}") - - logger.info(f"Calling Disbursement Components for Retry Transaction ID Data Received for :::: {transactionId}") - loan = LoanService.get_loan_by_transaction_id(transactionId) - if not loan: - logger.info(f"No loan found without disbursement date") - return 0 - logger.info(f"Calling DisburseLoan endpoint with data: {loan}") - loan_data = loan.to_dict() - - data = { - "transactionId": loan_data.get('transactionId'), - "FbnTransactionId": loan_data.get('transactionId'), - "debtId": str(loan_data.get('debtId')), - "customerId": loan_data.get('customerId'), - "accountId": loan_data.get('accountId'), - "productId": str(loan_data.get('productId', "")), - "provideAmount": loan_data.get('currentLoanAmount'), - } - response = SimbrellaClient.disburse_loan(data) - # return response - logger.info(f"Retry Disbursement Transaction ID Result Received for :::: {response}") - return ResponseHelper.success(message="Retry Disbursement Request Sent Successfully", status_code=200) - except Exception as e: - logger.error(f"Failed to call retry disbursement {data}: {e}") - - - - - -@autocall_bp.route("/direct/loan", methods=["POST"]) -def direct_loan(): - data = request.get_json() - logger.info(f"Data received: {data}") - - REQUIRED_KEYS = [ - "transactionId" - ] - - # Check for missing keys - missing_keys = [key for key in REQUIRED_KEYS if key not in data or data[key] is None] - if missing_keys: - logger.warning(f"Missing required keys: {missing_keys}") - return jsonify({ - "status": "error", - "message": f"Missing required fields: {', '.join(missing_keys)}" - }), 400 - - # Check if the loan exists - logger.info(f"Checking if loan with transaction id {data['transactionId']} exists") - transaction_id = data["transactionId"].strip() - - loan = LoanService.get_loan_by_transaction_id(transaction_id=transaction_id) - if not loan: - logger.warning(f"Loan with transaction id {transaction_id} does not exist") - return jsonify({ - "status": "error", - "message": f"Loan with transaction id {transaction_id} does not exist" - }), 400 - - loan_data = loan.to_dict() - - # Prevent double disbursement - if loan_data.get('disburseDate') is not None: - return jsonify({ - "status": "error", - "message": f"Loan with transaction id {data['transactionId']} has already been processed" - }), 400 - - data_to_process = { - "transactionId": loan_data.get('transactionId'), - "FbnTransactionId": loan_data.get('transactionId'), - "debtId": str(loan_data.get('debtId')), - "customerId": loan_data.get('customerId'), - "accountId": loan_data.get('accountId'), - "productId": str(loan_data.get('productId', "")), - "provideAmount": loan_data.get('currentLoanAmount'), - } - response = SimbrellaClient.disburse_loan(data_to_process) - return response - -@autocall_bp.route("/direct/repayment", methods=["POST"]) -def direct_repayment(): - data = request.get_json() - logger.info(f"Data received: {data}") - - REQUIRED_KEYS = ["transactionId"] - - # Check for missing keys - missing_keys = [key for key in REQUIRED_KEYS if key not in data or data[key] is None] - if missing_keys: - logger.warning(f"Missing required keys: {missing_keys}") - return jsonify({ - "status": "error", - "message": f"Missing required fields: {', '.join(missing_keys)}" - }), 400 - - # Check if the loan exists - logger.info(f"Checking if loan with transaction id {data['transactionId']} exists") - loan = LoanService.get_loan_by_transaction_id(transaction_id=data['transactionId']) - if not loan: - logger.info(f"Loan with transaction id {data['transactionId']} does not exist") - return jsonify({ - "status": "error", - "message": f"Loan with transaction id {data['transactionId']} does not exist" - }), 400 - - loan_data = loan.to_dict() - - # check if loan has been repaid - if loan_data.get("status") == LoanStatus.REPAID and loan_data.get("balance") <= 0: - logger.info(f"Loan with Id {loan_data.get('debtId')} has been repaid") - return jsonify({ - "status": "error", - "message": f"loan with Id {loan_data.get('debtId')} has been repaid" - }), 400 - - - repayment_data = { - "customerId": loan_data.get("customerId"), - "loanId": loan_data.get("debtId"), - "productId": loan_data.get("productId"), - "transactionId": loan_data.get("transactionId"), - "initiatedBy": "USER INITIATED", - "salaryAmount": 0, - "LoanStatus": loan_data.get("status"), - } - - logger.info(f"Creating repayment with data: {repayment_data}") - - try: - repayment = RepaymentService.create_repayment(repayment_data) - logger.info(f"Repayment created: {repayment}") - except Exception as e: - db.session.rollback() - logger.error(f"Repayment creation raised exception: {e}") - return jsonify({ - "status": "error", - "message": "Failed to create repayment" - }), 500 - - if not repayment or (isinstance(repayment, dict) and "error" in repayment): - db.session.rollback() - logger.error(f"Repayment creation failed for loan ID {loan_data.get('debtId')}: {repayment}") - - try: - if loan_data.get('status') == LoanStatus.ACTIVE: - LoanService.update_status(loan_id=loan_data.get('debtId'), status=LoanStatus.START_REPAY) - except Exception as e: - db.session.rollback() - logger.error(f"Failed to update loan status for loan ID {loan_data.get('debtId')}: {e}") - repayment_data_dict = repayment.to_dict() - - data_to_process = { - "transactionId": repayment_data_dict['transactionId'], - "debtId": repayment_data_dict['loanId'], - "customerId": repayment_data_dict['customerId'], - "productId": repayment_data_dict['productId'], - "Id":repayment_data_dict['Id'] - } - - response = SimbrellaClient.collect_loan_user_initiated(data_to_process) - return response - - - - -@autocall_bp.route("/refresh-verify-collection", methods=["GET"]) -def refresh_verify_collection(): - data = request.get_json() - logger.info(f"Calling Verify Collection") - - response = SimbrellaClient.collect_loan(data) - - return response - -@autocall_bp.route("/refresh-collection", methods=["GET"]) -def refresh_collection(): - #data = request.get_json() - logger.info(f"Calling Collection ") - #grab the last repayments with repay date is none - repayment = RepaymentService.get_latest_repayment_without_repay_date() - #repayment = RepaymentService.get_latest_repayment_with_loanId(13735) - if not repayment: - logger.info(f"No repayment found without repay date") - return 0 - logger.info(f"Calling repay loan endpoint with data: {repayment}") - repayment_data = repayment.to_dict() - logger.info(f"here is the dict form of repayment {repayment_data}") - - data = { - "transactionId": repayment_data['transactionId'], - "debtId": repayment_data['loanId'], - "customerId": repayment_data['customerId'], - "productId": repayment_data['productId'], - "Id":repayment_data['Id'] - } - logger.info(f"Data being sent to Simbrella: {data}") - logger.info(f"calling simbrella") - response = SimbrellaClient.collect_loan_user_initiated(data) - - return response - -@autocall_bp.route("/payment-callback", methods=["POST"]) -def payment_callback(): - data = request.get_json() - logger.info(f"Calling Callback Components") - - response = SimbrellaClient.payment_callback(data) - - return response - -@autocall_bp.route("/penal-charge", methods=["POST"]) -def penal_charge(): - data = request.get_json() - logger.info(f"Calling Penal Charge Endpoint") - - try: - response = SimbrellaClient.penal_charge(data[0]) - return response - except Exception as e: - logger.error(f"Error in Penal Charge: {e}") - return ResponseHelper.error("Penal charge failed") - - -@autocall_bp.route("/analytic-salary-detect", methods=["POST"]) -def salary_detect(): - payload = request.get_json() - logger.info("Calling Salary Detect endpoint") - - if payload is None: - logger.warning("No payload received in request") - return ResponseHelper.error("Missing request payload", status_code=400) - - # Step 1: Try to add new salary data - try: - new_salary = SalaryService.add_salary_data(payload) # TODO - This will come as array of salaries - not just one - if new_salary: - logger.info(f"Salary added: {new_salary.id}") - except Exception as e: - logger.error(f"Failed to save salary: {e}") - - # Step 2: Try processing salary list - try: - process_salary_list() - except Exception as e: - logger.exception("Unhandled error occurred while processing salary list {e}") - return ResponseHelper.error("Failed to process salary list", status_code=500, error=str(e)) - - logger.info("Finished processing List") - return ResponseHelper.success([], "AutoCall Add Salary Successful") - - -@autocall_bp.route("/analytic-salary-process", methods=["POST"]) -def salary_process(): - response = process_salary_list() - return ResponseHelper.success([], "AutoCall Successful") - - -def process_salary_list(): - # Step 1: Get all pending salaries - pending_salaries = SalaryService.get_pending_salaries() - if not pending_salaries: - logger.info("No pending salaries found") - return ResponseHelper.success([], "No pending salaries") - - logger.info(f"Found {len(pending_salaries)} pending salaries to process") - - for pending_salary in pending_salaries: - logger.info(f"Processing salary ID: {pending_salary.id}") - - # Step 2: Update salary status to PROCESSING - try: - SalaryService.update_status(pending_salary.id, "PROCESSING") - except Exception as e: - db.session.rollback() - logger.warning(f"Could not update status for salary ID {pending_salary.id}: {e}") - continue - - # Step 3: Get customer's active loans - try: - loans, total_amount = LoanService.get_customer_active_loans(pending_salary.customer_id) - if not loans: - logger.warning(f"No loans found for customer ID: {pending_salary.customer_id}") - continue - except Exception as e: - db.session.rollback() - logger.error(f"Error fetching loans for customer ID {pending_salary.customer_id}: {e}") - continue - - # Step 4: Create repayments for each loan - for loan in loans: - logger.info(f"Processing Loan ID: {loan.id}") - #check if the loan has been repaid - if loan.status in [LoanStatus.REPAID] and loan.balance <= 0: - logger.info(f"Skipping loan ID {loan.id} because it is already repaid/closed") - continue - - try: - repayment_data = { - "customerId": loan.customer_id, - "loanId": loan.id, - "productId": loan.product_id, - "transactionId": loan.transaction_id, - "initiatedBy": "SALARY_DETECT", - "salaryAmount": pending_salary.amount, - "LoanStatus": loan.status, - } - - logger.info(f"Creating repayment with data: {repayment_data}") - repayment = RepaymentService.create_repayment(repayment_data) - - if not repayment or isinstance(repayment, dict) and "error" in repayment: - db.session.rollback() # important in case create_repayment failed mid-way - logger.error(f"Repayment creation failed for loan ID {loan.id}: {repayment}") - continue - - try: - if loan.status == LoanStatus.ACTIVE: - LoanService.update_status(loan_id=loan.id, status=LoanStatus.START_REPAY) - except Exception as e: - db.session.rollback() - logger.error(f"Failed to update loan status for loan ID {loan.id}: {e}") - - logger.info(f"Created repayment ID: {repayment.id}") - - # Step 5: Call Simbrella - try: - simbrella_response = SimbrellaClient.collect_loan_user_salary_detect(repayment.to_dict()) - - if isinstance(simbrella_response, tuple): - simbrella_response, status_code = simbrella_response - logger.warning(f"Simbrella returned tuple: status={status_code}, response={simbrella_response}") - - if isinstance(simbrella_response, dict): - if simbrella_response.get("status") != "success": - logger.warning(f"Simbrella failed for repayment ID {repayment.id}: {simbrella_response}") - else: - logger.warning(f"Unexpected Simbrella response: {type(simbrella_response)}") - - except Exception as e: - logger.error(f"Failed to call Simbrella for repayment ID {repayment.id}: {e}") - - except Exception as e: - db.session.rollback() - logger.error(f"Error creating repayment for loan ID {loan.id}: {e}") - continue - - logger.info(f"Finished processing salary ID: {pending_salary.id}") - - return ResponseHelper.success([], "Processed all pending salaries") - - - -@autocall_bp.route("/report", methods=["GET"]) -def report(): - try: - report_data = get_report_data() - logger.info(f"Generated report data: {report_data}") - send_report_email( - report_data, - recipients = [email.strip() for email in settings.MAIL_RECEIVER.split(",")]) - logger.info(f"Report sent successfully") - return ResponseHelper.success(message="Report sent successfully",status_code=200) - except Exception as e: - logger.error(f"Error generating or sending report: {e}") - return ResponseHelper.error("Failed to send report", status_code=500, error=str(e)) - -@autocall_bp.route("/process-penal-charges", methods=["GET"]) -def process_penal_charges(): - try: - OVERDUE_GRACE_PERIOD_DAYS = settings.OVERDUE_GRACE_PERIOD_DAYS - OVERDUE_PROCESSING_LIST_LIMIT = settings.OVERDUE_PROCESSING_LIST_LIMIT - PENAL_CHARGE_MAXIMUM_COUNT = settings.PENAL_CHARGE_MAXIMUM_COUNT - PENAL_CHARGE_INTERVAL_DAYS = settings.PENAL_CHARGE_INTERVAL_DAYS - - now = datetime.now(timezone.utc) - - overdue_schedules = ( - LoanRepaymentScheduleService - .get_overdue_repayment_schedule_with_grace_period( - OVERDUE_GRACE_PERIOD_DAYS, - OVERDUE_PROCESSING_LIST_LIMIT - ) - ) - - logger.info(f"Found {len(overdue_schedules)} overdue loan schedule.") - - if not overdue_schedules: - return ResponseHelper.success( - message="No overdue loan schedule found", - status_code=200 - ) - - processed_loans = [] - - for schedule in overdue_schedules: - - loan = LoanService.get_loan_by_loan_id(schedule.loan_id) - - if not loan: - logger.info(f"Loan with id {schedule.loan_id} not found") - continue - - penal_count = schedule.penal_count or 0 - - # MAX PENAL CHECK - if penal_count >= PENAL_CHARGE_MAXIMUM_COUNT: - logger.info( - f"Penal count for schedule {schedule.id} has reached the maximum limit." - ) - continue - - # INTERVAL CHECK (PER SCHEDULE) - if schedule.last_penal_date: - # ensure last_penal_date is timezone-aware - last_penal = schedule.last_penal_date - if last_penal.tzinfo is None: - last_penal = last_penal.replace(tzinfo=timezone.utc) - - next_allowed_date = last_penal + timedelta(days=PENAL_CHARGE_INTERVAL_DAYS) - - if now < next_allowed_date: - logger.info( - f"Penal interval for schedule {schedule.id} has not passed yet." - ) - continue - - # NEXT PENAL NUMBER - next_penal_no = penal_count + 1 - - # CALCULATE PENAL - penal_amount = LoanRepaymentScheduleService.calculate_penal_charge(schedule) - - # CREATE PENAL CHARGE - new_penal_charge = LoanChargesService.create_penal_charges_for_loan( - loan_id=schedule.loan_id, - transaction_id=schedule.transaction_id, - percent=settings.PENAL_CHARGE_PERCENTAGE, - penal_no=next_penal_no, - schedule_number=schedule.installment_number, - penal_amount=penal_amount - ) - - if not new_penal_charge: - logger.error(f"Failed to create penal charge for loan ID: {loan.id}") - continue - - logger.info(f"Penal charge created: {new_penal_charge.to_dict()}") - - # UPDATE SCHEDULE - LoanRepaymentScheduleService.apply_penal_to_schedule( - schedule.id, - penal_amount - ) - - logger.info(f"Penal charge applied to schedule {schedule.id}") - - # UPDATE LOAN TOTAL - LoanService.apply_penal_to_loan( - loan.id, - penal_amount - ) - - logger.info(f"Penal charge applied to loan {loan.id}") - - processed_loans.append(loan.to_dict()) - - return ResponseHelper.success( - message="Penal Charges Processed Successfully", - status_code=200, - data=processed_loans - ) - - except Exception as e: - logger.exception(f"Error processing penal charges: {e}") - return ResponseHelper.error( - "Failed to process penal charges", - status_code=500, - error=str(e) - ) - -@autocall_bp.route("/overdue-loans", methods=["GET"]) -def overdue_loans(): - try: - # Step 1: Get all active overdue loans - overdue_loans = LoanRepaymentScheduleService.get_active_overdue_repayment_schedule() - logger.info(f"Found {len(overdue_loans)} overdue loans.") - - if not overdue_loans: - logger.info("No overdue loans found.") - return ResponseHelper.success(message="No overdue loans found", status_code=200) - #get batch size from settings - loan_delay_seconds = max(0, settings.OVERDUE_LOAN_DELAY_SECONDS) - batch_delay_seconds = max(0, settings.OVERDUE_LOAN_BATCH_DELAY_SECONDS) - batch_size = max(1, settings.OVERDUE_LOAN_BATCH_SIZE) - - loan_chunks = list(CollectLoanHelper.chunk_list(overdue_loans, batch_size)) - logger.info(f"Found {len(loan_chunks)} loan chunks to process.") - - - # Step 2: Process each loan - for chunk_index, loan_chunk in enumerate(loan_chunks): - logger.info(f"Processing chunk {chunk_index + 1} of {len(loan_chunks)} with {len(loan_chunk)} loans.") - for loan in loan_chunk: - try: - process_overdue_loan(loan) - except Exception: - logger.exception(f"Failed processing loan {loan.id}") - finally: - time_module.sleep(loan_delay_seconds) - if chunk_index < len(loan_chunks) - 1: - logger.info(f"Waiting {batch_delay_seconds} seconds before processing next chunk...") - time_module.sleep(batch_delay_seconds) # Delay between chunks - - return ResponseHelper.success(message="Processed overdue loans successfully", status_code=200) - - except Exception as e: - logger.exception(f"Error fetching overdue loans: {e}") - return ResponseHelper.error("Failed to fetch overdue loans", status_code=500, error=str(e)) - - -def process_overdue_loan(loan): - """ - Handles repayment creation, loan status update, and Simbrella call - for a single overdue loan. - """ - logger.info(f"Processing Loan ID: {loan.loan_id}") - full_loan_data = LoanService.get_loan_by_loan_id(loan.loan_id) - logger.info(f"full loan details: {full_loan_data.to_dict()}") - if not full_loan_data: - logger.warning(f"Full Loan ID {loan.loan_id} not found in database") - else: - #lets check if the loan with the repayment has been repaid, then update the loan schedule to paid - if full_loan_data.to_dict().get("status") == LoanStatus.REPAID and full_loan_data.to_dict().get("balance") == 0: - try: - LoanRepaymentScheduleService.update_repayment_schedule_status(loan.id) - logger.info(f"Updated Loan Repayment Schedule ID {loan.id} to PAID") - return - except Exception as e: - db.session.rollback() - logger.error(f"Failed to update Loan Repayment Schedule ID {loan.id} to PAID: {e}") - - customer_id = full_loan_data.to_dict().get("customerId") - loan_status = full_loan_data.to_dict().get("status") - - try: - repayment_data = { - "customerId": customer_id, - "loanId": loan.loan_id, - "productId": loan.product_id, - "transactionId": loan.transaction_id, - "initiatedBy": "SYSTEM", # To be reviewed - "salaryAmount": 0, - "LoanStatus": loan_status, - } - - logger.info(f"Creating repayment with data: {repayment_data}") - repayment = RepaymentService.create_repayment(repayment_data) - - if not repayment or (isinstance(repayment, dict) and "error" in repayment): - db.session.rollback() # important in case create_repayment failed mid-way - logger.error(f"Repayment creation failed for loan ID {loan.loan_id}: {repayment}") - return - - # Update loan status - try: - logger.info(f"Updating loan status for loan ID {loan.loan_id}") - LoanService.update_status(loan_id=loan.loan_id, status=LoanStatus.START_REPAY) - except Exception as e: - db.session.rollback() - logger.error(f"Failed to update loan status for loan ID {loan.loan_id}: {e}") - - logger.info(f"Created repayment ID: {repayment.id}") - - # Step 3: Call Simbrella - try: - #lets add the overdue loan schedule id and amount we are currently processing to the repayment data - if loan.paid_status == RepaymentScheduleStatus.PARTIALLY_PAID: - amount = loan.partial_balance or 0 - else: - amount = loan.installment_amount - repayment_data["overdueLoanScheduleAmount"] = amount - repayment_data["overdueLoanScheduleId"] = loan.id - repayment_data["Id"] = repayment.id - logger.info(f"Calling Simbrella for with repayment data: {repayment_data}") - simbrella_response = SimbrellaClient.collect_loan_user_due_payment(repayment_data) - - if isinstance(simbrella_response, tuple): - simbrella_response, status_code = simbrella_response - logger.warning(f"Simbrella returned tuple: status={status_code}, response={simbrella_response}") - - if isinstance(simbrella_response, dict): - if simbrella_response.get("status") != "success": - logger.warning(f"Simbrella failed for repayment ID {repayment.id}: {simbrella_response}") - else: - logger.warning(f"Unexpected Simbrella response: {type(simbrella_response)}") - - except Exception as e: - logger.error(f"Failed to call Simbrella for repayment ID {repayment.id}: {e}") - - except Exception as e: - db.session.rollback() - logger.error(f"Error creating repayment for loan ID {loan.id}: {e}") - - finally: - logger.info(f"Finished processing loan ID: {loan.id}") +import time as time_module +from flask import Blueprint, request, jsonify, current_app +import requests +from app.extensions import db +from app.config import settings +from app.helpers.response_helper import ResponseHelper +from app.helpers.collect_loan_helper import CollectLoanHelper +from app.utils.auth import get_headers +from app.utils.logger import logger +from app.integrations.simbrella import SimbrellaClient +from app.services.loan import LoanService +from app.services.repayment import RepaymentService +from app.services.salary import SalaryService +from app.services.loan_repayment_schedule import LoanRepaymentScheduleService +from app.services.loan_charge import LoanChargesService +from app.enums.loan_status import LoanStatus +from app.enums.repayment_schedule_status import RepaymentScheduleStatus +from app.utils.mail import send_report_email, get_report_data +from datetime import datetime, timezone, timedelta +from app.config import settings + +autocall_bp = Blueprint("autocall", __name__) + + +@autocall_bp.route("/refresh-verify-disbursement", methods=["GET"]) +def verify_transaction(): + logger.info(f"Calling VerifyTransaction Components") + + loan = LoanService.get_latest_loan_with_disburse_date() + if not loan: + logger.info(f"No loan found without disbursement date") + return 0 + logger.info(f"Calling VerifyTransaction endpoint with data: {loan}") + loan_data = loan.to_dict() + + data = { + "transactionId": loan_data.get('transactionId'), + "fbnTransactionId": loan_data.get('transactionId'), + "debtId": str(loan_data.get('debtId')), + "customerId": loan_data.get('customerId'), + "accountId": loan_data.get('accountId'), + "productId": str(loan_data.get('productId', "")), + "provideAmount": loan_data.get('currentLoanAmount'), + } + response = SimbrellaClient.verify_transaction(data) + return response + +@autocall_bp.route("/refresh-disbursement", methods=["GET"]) +def disbursement(): + # data = request.json() + logger.info(f"Calling Disbursement Components") + loan = LoanService.get_latest_loan_without_disburse_date() + if not loan: + logger.info(f"No loan found without disbursement date") + return 0 + logger.info(f"Calling DisburseLoan endpoint with data: {loan}") + loan_data = loan.to_dict() + + data = { + "transactionId": loan_data.get('transactionId'), + "FbnTransactionId": loan_data.get('transactionId'), + "debtId": str(loan_data.get('debtId')), + "customerId": loan_data.get('customerId'), + "accountId": loan_data.get('accountId'), + "productId": str(loan_data.get('productId', "")), + "provideAmount": loan_data.get('currentLoanAmount'), + } + response = SimbrellaClient.disburse_loan(data) + return response + +@autocall_bp.route("/retry-disbursement", methods=["POST"]) +def retry_disbursement(): + try: + data = request.get_json() + logger.info(f"Retry Transaction ID Data Received for :::: {data}") + + transactionId = data["transactionId"] + logger.info(f"Starting Transaction ID Data Received for :::: {transactionId}") + + logger.info(f"Calling Disbursement Components for Retry Transaction ID Data Received for :::: {transactionId}") + loan = LoanService.get_loan_by_transaction_id(transactionId) + if not loan: + logger.info(f"No loan found without disbursement date") + return 0 + logger.info(f"Calling DisburseLoan endpoint with data: {loan}") + loan_data = loan.to_dict() + + data = { + "transactionId": loan_data.get('transactionId'), + "FbnTransactionId": loan_data.get('transactionId'), + "debtId": str(loan_data.get('debtId')), + "customerId": loan_data.get('customerId'), + "accountId": loan_data.get('accountId'), + "productId": str(loan_data.get('productId', "")), + "provideAmount": loan_data.get('currentLoanAmount'), + } + response = SimbrellaClient.disburse_loan(data) + # return response + logger.info(f"Retry Disbursement Transaction ID Result Received for :::: {response}") + return ResponseHelper.success(message="Retry Disbursement Request Sent Successfully", status_code=200) + except Exception as e: + logger.error(f"Failed to call retry disbursement {data}: {e}") + + + + + +@autocall_bp.route("/direct/loan", methods=["POST"]) +def direct_loan(): + data = request.get_json() + logger.info(f"Data received: {data}") + + REQUIRED_KEYS = [ + "transactionId" + ] + + # Check for missing keys + missing_keys = [key for key in REQUIRED_KEYS if key not in data or data[key] is None] + if missing_keys: + logger.warning(f"Missing required keys: {missing_keys}") + return jsonify({ + "status": "error", + "message": f"Missing required fields: {', '.join(missing_keys)}" + }), 400 + + # Check if the loan exists + logger.info(f"Checking if loan with transaction id {data['transactionId']} exists") + transaction_id = data["transactionId"].strip() + + loan = LoanService.get_loan_by_transaction_id(transaction_id=transaction_id) + if not loan: + logger.warning(f"Loan with transaction id {transaction_id} does not exist") + return jsonify({ + "status": "error", + "message": f"Loan with transaction id {transaction_id} does not exist" + }), 400 + + loan_data = loan.to_dict() + + # Prevent double disbursement + if loan_data.get('disburseDate') is not None: + return jsonify({ + "status": "error", + "message": f"Loan with transaction id {data['transactionId']} has already been processed" + }), 400 + + data_to_process = { + "transactionId": loan_data.get('transactionId'), + "FbnTransactionId": loan_data.get('transactionId'), + "debtId": str(loan_data.get('debtId')), + "customerId": loan_data.get('customerId'), + "accountId": loan_data.get('accountId'), + "productId": str(loan_data.get('productId', "")), + "provideAmount": loan_data.get('currentLoanAmount'), + } + response = SimbrellaClient.disburse_loan(data_to_process) + return response + +@autocall_bp.route("/direct/repayment", methods=["POST"]) +def direct_repayment(): + data = request.get_json() + logger.info(f"Data received: {data}") + + REQUIRED_KEYS = ["transactionId"] + + # Check for missing keys + missing_keys = [key for key in REQUIRED_KEYS if key not in data or data[key] is None] + if missing_keys: + logger.warning(f"Missing required keys: {missing_keys}") + return jsonify({ + "status": "error", + "message": f"Missing required fields: {', '.join(missing_keys)}" + }), 400 + + # Check if the loan exists + logger.info(f"Checking if loan with transaction id {data['transactionId']} exists") + loan = LoanService.get_loan_by_transaction_id(transaction_id=data['transactionId']) + if not loan: + logger.info(f"Loan with transaction id {data['transactionId']} does not exist") + return jsonify({ + "status": "error", + "message": f"Loan with transaction id {data['transactionId']} does not exist" + }), 400 + + loan_data = loan.to_dict() + + # check if loan has been repaid + if loan_data.get("status") == LoanStatus.REPAID and loan_data.get("balance") <= 0: + logger.info(f"Loan with Id {loan_data.get('debtId')} has been repaid") + return jsonify({ + "status": "error", + "message": f"loan with Id {loan_data.get('debtId')} has been repaid" + }), 400 + + + repayment_data = { + "customerId": loan_data.get("customerId"), + "loanId": loan_data.get("debtId"), + "productId": loan_data.get("productId"), + "transactionId": loan_data.get("transactionId"), + "initiatedBy": "USER INITIATED", + "salaryAmount": 0, + "LoanStatus": loan_data.get("status"), + } + + logger.info(f"Creating repayment with data: {repayment_data}") + + try: + repayment = RepaymentService.create_repayment(repayment_data) + logger.info(f"Repayment created: {repayment}") + except Exception as e: + db.session.rollback() + logger.error(f"Repayment creation raised exception: {e}") + return jsonify({ + "status": "error", + "message": "Failed to create repayment" + }), 500 + + if not repayment or (isinstance(repayment, dict) and "error" in repayment): + db.session.rollback() + logger.error(f"Repayment creation failed for loan ID {loan_data.get('debtId')}: {repayment}") + + try: + if loan_data.get('status') == LoanStatus.ACTIVE: + LoanService.update_status(loan_id=loan_data.get('debtId'), status=LoanStatus.START_REPAY) + except Exception as e: + db.session.rollback() + logger.error(f"Failed to update loan status for loan ID {loan_data.get('debtId')}: {e}") + repayment_data_dict = repayment.to_dict() + + data_to_process = { + "transactionId": repayment_data_dict['transactionId'], + "debtId": repayment_data_dict['loanId'], + "customerId": repayment_data_dict['customerId'], + "productId": repayment_data_dict['productId'], + "Id":repayment_data_dict['Id'] + } + + response = SimbrellaClient.collect_loan_user_initiated(data_to_process) + return response + + + + +@autocall_bp.route("/refresh-verify-collection", methods=["GET"]) +def refresh_verify_collection(): + data = request.get_json() + logger.info(f"Calling Verify Collection") + + response = SimbrellaClient.collect_loan(data) + + return response + +@autocall_bp.route("/refresh-collection", methods=["GET"]) +def refresh_collection(): + #data = request.get_json() + logger.info(f"Calling Collection ") + #grab the last repayments with repay date is none + repayment = RepaymentService.get_latest_repayment_without_repay_date() + #repayment = RepaymentService.get_latest_repayment_with_loanId(13735) + if not repayment: + logger.info(f"No repayment found without repay date") + return 0 + logger.info(f"Calling repay loan endpoint with data: {repayment}") + repayment_data = repayment.to_dict() + logger.info(f"here is the dict form of repayment {repayment_data}") + + data = { + "transactionId": repayment_data['transactionId'], + "debtId": repayment_data['loanId'], + "customerId": repayment_data['customerId'], + "productId": repayment_data['productId'], + "Id":repayment_data['Id'] + } + logger.info(f"Data being sent to Simbrella: {data}") + logger.info(f"calling simbrella") + response = SimbrellaClient.collect_loan_user_initiated(data) + + return response + +@autocall_bp.route("/payment-callback", methods=["POST"]) +def payment_callback(): + data = request.get_json() + logger.info(f"Calling Callback Components") + + response = SimbrellaClient.payment_callback(data) + + return response + +@autocall_bp.route("/penal-charge", methods=["POST"]) +def penal_charge(): + data = request.get_json() + logger.info(f"Calling Penal Charge Endpoint") + + try: + response = SimbrellaClient.penal_charge(data[0]) + return response + except Exception as e: + logger.error(f"Error in Penal Charge: {e}") + return ResponseHelper.error("Penal charge failed") + + +@autocall_bp.route("/analytic-salary-detect", methods=["POST"]) +def salary_detect(): + payload = request.get_json() + logger.info("Calling Salary Detect endpoint") + + if payload is None: + logger.warning("No payload received in request") + return ResponseHelper.error("Missing request payload", status_code=400) + + # Step 1: Try to add new salary data + try: + new_salary = SalaryService.add_salary_data(payload) # TODO - This will come as array of salaries - not just one + if new_salary: + logger.info(f"Salary added: {new_salary.id}") + except Exception as e: + logger.error(f"Failed to save salary: {e}") + + # Step 2: Try processing salary list + try: + process_salary_list() + except Exception as e: + logger.exception("Unhandled error occurred while processing salary list {e}") + return ResponseHelper.error("Failed to process salary list", status_code=500, error=str(e)) + + logger.info("Finished processing List") + return ResponseHelper.success([], "AutoCall Add Salary Successful") + + +@autocall_bp.route("/analytic-salary-process", methods=["POST"]) +def salary_process(): + response = process_salary_list() + return ResponseHelper.success([], "AutoCall Successful") + + +def process_salary_list(): + # Step 1: Get all pending salaries + pending_salaries = SalaryService.get_pending_salaries() + if not pending_salaries: + logger.info("No pending salaries found") + return ResponseHelper.success([], "No pending salaries") + + logger.info(f"Found {len(pending_salaries)} pending salaries to process") + + for pending_salary in pending_salaries: + logger.info(f"Processing salary ID: {pending_salary.id}") + + # Step 2: Update salary status to PROCESSING + try: + SalaryService.update_status(pending_salary.id, "PROCESSING") + except Exception as e: + db.session.rollback() + logger.warning(f"Could not update status for salary ID {pending_salary.id}: {e}") + continue + + # Step 3: Get customer's active loans + try: + loans, total_amount = LoanService.get_customer_active_loans(pending_salary.customer_id) + if not loans: + logger.warning(f"No loans found for customer ID: {pending_salary.customer_id}") + continue + except Exception as e: + db.session.rollback() + logger.error(f"Error fetching loans for customer ID {pending_salary.customer_id}: {e}") + continue + + # Step 4: Create repayments for each loan + for loan in loans: + logger.info(f"Processing Loan ID: {loan.id}") + #check if the loan has been repaid + if loan.status in [LoanStatus.REPAID] and loan.balance <= 0: + logger.info(f"Skipping loan ID {loan.id} because it is already repaid/closed") + continue + + try: + repayment_data = { + "customerId": loan.customer_id, + "loanId": loan.id, + "productId": loan.product_id, + "transactionId": loan.transaction_id, + "initiatedBy": "SALARY_DETECT", + "salaryAmount": pending_salary.amount, + "LoanStatus": loan.status, + } + + logger.info(f"Creating repayment with data: {repayment_data}") + repayment = RepaymentService.create_repayment(repayment_data) + + if not repayment or isinstance(repayment, dict) and "error" in repayment: + db.session.rollback() # important in case create_repayment failed mid-way + logger.error(f"Repayment creation failed for loan ID {loan.id}: {repayment}") + continue + + try: + if loan.status == LoanStatus.ACTIVE: + LoanService.update_status(loan_id=loan.id, status=LoanStatus.START_REPAY) + except Exception as e: + db.session.rollback() + logger.error(f"Failed to update loan status for loan ID {loan.id}: {e}") + + logger.info(f"Created repayment ID: {repayment.id}") + + # Step 5: Call Simbrella + try: + simbrella_response = SimbrellaClient.collect_loan_user_salary_detect(repayment.to_dict()) + + if isinstance(simbrella_response, tuple): + simbrella_response, status_code = simbrella_response + logger.warning(f"Simbrella returned tuple: status={status_code}, response={simbrella_response}") + + if isinstance(simbrella_response, dict): + if simbrella_response.get("status") != "success": + logger.warning(f"Simbrella failed for repayment ID {repayment.id}: {simbrella_response}") + else: + logger.warning(f"Unexpected Simbrella response: {type(simbrella_response)}") + + except Exception as e: + logger.error(f"Failed to call Simbrella for repayment ID {repayment.id}: {e}") + + except Exception as e: + db.session.rollback() + logger.error(f"Error creating repayment for loan ID {loan.id}: {e}") + continue + + logger.info(f"Finished processing salary ID: {pending_salary.id}") + + return ResponseHelper.success([], "Processed all pending salaries") + + + +@autocall_bp.route("/report", methods=["GET"]) +def report(): + try: + report_data = get_report_data() + logger.info(f"Generated report data: {report_data}") + send_report_email( + report_data, + recipients = [email.strip() for email in settings.MAIL_RECEIVER.split(",")]) + logger.info(f"Report sent successfully") + return ResponseHelper.success(message="Report sent successfully",status_code=200) + except Exception as e: + logger.error(f"Error generating or sending report: {e}") + return ResponseHelper.error("Failed to send report", status_code=500, error=str(e)) + +@autocall_bp.route("/process-penal-charges", methods=["GET"]) +def process_penal_charges(): + try: + OVERDUE_GRACE_PERIOD_DAYS = settings.OVERDUE_GRACE_PERIOD_DAYS + OVERDUE_PROCESSING_LIST_LIMIT = settings.OVERDUE_PROCESSING_LIST_LIMIT + PENAL_CHARGE_MAXIMUM_COUNT = settings.PENAL_CHARGE_MAXIMUM_COUNT + PENAL_CHARGE_INTERVAL_DAYS = settings.PENAL_CHARGE_INTERVAL_DAYS + + now = datetime.now(timezone.utc) + + overdue_schedules = ( + LoanRepaymentScheduleService + .get_overdue_repayment_schedule_with_grace_period( + OVERDUE_GRACE_PERIOD_DAYS, + OVERDUE_PROCESSING_LIST_LIMIT + ) + ) + + logger.info(f"Found {len(overdue_schedules)} overdue loan schedule.") + + if not overdue_schedules: + return ResponseHelper.success( + message="No overdue loan schedule found", + status_code=200 + ) + + processed_loans = [] + + for schedule in overdue_schedules: + + loan = LoanService.get_loan_by_loan_id(schedule.loan_id) + + if not loan: + logger.info(f"Loan with id {schedule.loan_id} not found") + continue + + penal_count = schedule.penal_count or 0 + + # MAX PENAL CHECK + if penal_count >= PENAL_CHARGE_MAXIMUM_COUNT: + logger.info( + f"Penal count for schedule {schedule.id} has reached the maximum limit." + ) + continue + + # INTERVAL CHECK (PER SCHEDULE) + if schedule.last_penal_date: + # ensure last_penal_date is timezone-aware + last_penal = schedule.last_penal_date + if last_penal.tzinfo is None: + last_penal = last_penal.replace(tzinfo=timezone.utc) + + next_allowed_date = last_penal + timedelta(days=PENAL_CHARGE_INTERVAL_DAYS) + + if now < next_allowed_date: + logger.info( + f"Penal interval for schedule {schedule.id} has not passed yet." + ) + continue + + # NEXT PENAL NUMBER + next_penal_no = penal_count + 1 + + # CALCULATE PENAL + penal_amount = LoanRepaymentScheduleService.calculate_penal_charge(schedule) + + # CREATE PENAL CHARGE + new_penal_charge = LoanChargesService.create_penal_charges_for_loan( + loan_id=schedule.loan_id, + transaction_id=schedule.transaction_id, + percent=settings.PENAL_CHARGE_PERCENTAGE, + penal_no=next_penal_no, + schedule_number=schedule.installment_number, + penal_amount=penal_amount + ) + + if not new_penal_charge: + logger.error(f"Failed to create penal charge for loan ID: {loan.id}") + continue + + logger.info(f"Penal charge created: {new_penal_charge.to_dict()}") + + # UPDATE SCHEDULE + LoanRepaymentScheduleService.apply_penal_to_schedule( + schedule.id, + penal_amount + ) + + logger.info(f"Penal charge applied to schedule {schedule.id}") + + # UPDATE LOAN TOTAL + LoanService.apply_penal_to_loan( + loan.id, + penal_amount + ) + + logger.info(f"Penal charge applied to loan {loan.id}") + + processed_loans.append(loan.to_dict()) + + return ResponseHelper.success( + message="Penal Charges Processed Successfully", + status_code=200, + data=processed_loans + ) + + except Exception as e: + logger.exception(f"Error processing penal charges: {e}") + return ResponseHelper.error( + "Failed to process penal charges", + status_code=500, + error=str(e) + ) + +@autocall_bp.route("/overdue-loans", methods=["GET"]) +def overdue_loans(): + try: + # Step 1: Get all active overdue loans + overdue_loans = LoanRepaymentScheduleService.get_active_overdue_repayment_schedule() + logger.info(f"Found {len(overdue_loans)} overdue loans.") + + if not overdue_loans: + logger.info("No overdue loans found.") + return ResponseHelper.success(message="No overdue loans found", status_code=200) + #get batch size from settings + loan_delay_seconds = max(0, settings.OVERDUE_LOAN_DELAY_SECONDS) + batch_delay_seconds = max(0, settings.OVERDUE_LOAN_BATCH_DELAY_SECONDS) + batch_size = max(1, settings.OVERDUE_LOAN_BATCH_SIZE) + + loan_chunks = list(CollectLoanHelper.chunk_list(overdue_loans, batch_size)) + logger.info(f"Found {len(loan_chunks)} loan chunks to process.") + + + # Step 2: Process each loan + for chunk_index, loan_chunk in enumerate(loan_chunks): + logger.info(f"Processing chunk {chunk_index + 1} of {len(loan_chunks)} with {len(loan_chunk)} loans.") + for loan in loan_chunk: + try: + process_overdue_loan(loan) + except Exception: + logger.exception(f"Failed processing loan {loan.id}") + finally: + time_module.sleep(loan_delay_seconds) + if chunk_index < len(loan_chunks) - 1: + logger.info(f"Waiting {batch_delay_seconds} seconds before processing next chunk...") + time_module.sleep(batch_delay_seconds) # Delay between chunks + + return ResponseHelper.success(message="Processed overdue loans successfully", status_code=200) + + except Exception as e: + logger.exception(f"Error fetching overdue loans: {e}") + return ResponseHelper.error("Failed to fetch overdue loans", status_code=500, error=str(e)) + + +def process_overdue_loan(loan): + """ + Handles repayment creation, loan status update, and Simbrella call + for a single overdue loan. + """ + logger.info(f"Processing Loan ID: {loan.loan_id}") + full_loan_data = LoanService.get_loan_by_loan_id(loan.loan_id) + logger.info(f"full loan details: {full_loan_data.to_dict()}") + if not full_loan_data: + logger.warning(f"Full Loan ID {loan.loan_id} not found in database") + else: + #lets check if the loan with the repayment has been repaid, then update the loan schedule to paid + if full_loan_data.to_dict().get("status") == LoanStatus.REPAID and full_loan_data.to_dict().get("balance") == 0: + try: + LoanRepaymentScheduleService.update_repayment_schedule_status(loan.id) + logger.info(f"Updated Loan Repayment Schedule ID {loan.id} to PAID") + return + except Exception as e: + db.session.rollback() + logger.error(f"Failed to update Loan Repayment Schedule ID {loan.id} to PAID: {e}") + + customer_id = full_loan_data.to_dict().get("customerId") + loan_status = full_loan_data.to_dict().get("status") + + try: + repayment_data = { + "customerId": customer_id, + "loanId": loan.loan_id, + "productId": loan.product_id, + "transactionId": loan.transaction_id, + "initiatedBy": "SYSTEM", # To be reviewed + "salaryAmount": 0, + "LoanStatus": loan_status, + } + + logger.info(f"Creating repayment with data: {repayment_data}") + repayment = RepaymentService.create_repayment(repayment_data) + + if not repayment or (isinstance(repayment, dict) and "error" in repayment): + db.session.rollback() # important in case create_repayment failed mid-way + logger.error(f"Repayment creation failed for loan ID {loan.loan_id}: {repayment}") + return + + # Update loan status + try: + logger.info(f"Updating loan status for loan ID {loan.loan_id}") + LoanService.update_status(loan_id=loan.loan_id, status=LoanStatus.START_REPAY) + except Exception as e: + db.session.rollback() + logger.error(f"Failed to update loan status for loan ID {loan.loan_id}: {e}") + + logger.info(f"Created repayment ID: {repayment.id}") + + # Step 3: Call Simbrella + try: + #lets add the overdue loan schedule id and amount we are currently processing to the repayment data + if loan.paid_status == RepaymentScheduleStatus.PARTIALLY_PAID: + amount = loan.partial_balance or 0 + else: + amount = loan.installment_amount + repayment_data["overdueLoanScheduleAmount"] = amount + repayment_data["overdueLoanScheduleId"] = loan.id + repayment_data["Id"] = repayment.id + logger.info(f"Calling Simbrella for with repayment data: {repayment_data}") + simbrella_response = SimbrellaClient.collect_loan_user_due_payment(repayment_data) + + if isinstance(simbrella_response, tuple): + simbrella_response, status_code = simbrella_response + logger.warning(f"Simbrella returned tuple: status={status_code}, response={simbrella_response}") + + if isinstance(simbrella_response, dict): + if simbrella_response.get("status") != "success": + logger.warning(f"Simbrella failed for repayment ID {repayment.id}: {simbrella_response}") + else: + logger.warning(f"Unexpected Simbrella response: {type(simbrella_response)}") + + except Exception as e: + logger.error(f"Failed to call Simbrella for repayment ID {repayment.id}: {e}") + + except Exception as e: + db.session.rollback() + logger.error(f"Error creating repayment for loan ID {loan.id}: {e}") + + finally: + logger.info(f"Finished processing loan ID: {loan.id}") diff --git a/app/services/loan.py b/app/services/loan.py index d090dbf..9f800f6 100644 --- a/app/services/loan.py +++ b/app/services/loan.py @@ -1,148 +1,148 @@ -from app.models import Loan, LoanCharge -from app.utils.logger import logger -from app.enums.loan_status import LoanStatus -from decimal import Decimal, ROUND_HALF_UP -from app.services.loan_repayment_schedule import LoanRepaymentScheduleService - -class LoanService: - - @classmethod - def get_loan_by_transaction_id(cls, transaction_id): - """ - Get the loan by transaction ID - """ - return Loan.get_loan_by_transaction_id(transaction_id) - @classmethod - def get_loan_by_loan_id(cls, loan_id): - """ - Get the loan by ID - """ - return Loan.get_loan_by_loan_id(loan_id) - - - @classmethod - def get_loan_by_debt_id(cls, debt_id): - """ - Get the loan by transaction ID - """ - return Loan.get_loan_by_debt_id(debt_id) - - - @classmethod - def get_loan_charge_by_debt_id(cls, debt_id): - """ - Get the loan charge by debt ID - """ - return LoanCharge.get_loan_charge_by_debt_id(debt_id) - - @classmethod - def set_disbursement_date(cls, loan_id, customer_id): - """ - Update the disbursement status of the loan with the given loan_id. - """ - return Loan.set_disbursement_date(loan_id, customer_id) - @classmethod - def set_disburse_verify_date(cls, loan_id, customer_id): - """ - Update the disburse verify date of the loan with the given loan_id. - """ - return Loan.set_disburse_verify_date(loan_id, customer_id) - - @classmethod - def set_disbursement_result(cls, loan_id, result, description): - """ - Update the disbursement result of the loan with the given loan_id. - """ - return Loan.set_disbursement_result(loan_id, result, description) - - @classmethod - def set_disbursement_loan_description(cls,loan_id,description): - - return Loan.set_disbursement_message(loan_id, description) - - - @classmethod - def set_disburse_verify_result(cls, loan_id, result, description): - """ - Update the disburse verify result of the loan with the given loan_id. - """ - return Loan.set_disburse_verify_result(loan_id, result, description) - - @classmethod - def get_latest_loan_without_disburse_date(cls): - """ - Get the latest loan without a disbursement date. - """ - return Loan.get_latest_loan_without_disburse_date() - - @classmethod - def get_latest_loan_with_disburse_date(cls): - """ - Get the latest loan without a disbursement date. - """ - return Loan.get_latest_loan_with_disburse_date() - - @classmethod - def get_customer_loans(cls, customer_id): - """ - Get customer's active loans by customer_id. - """ - - return Loan.get_customer_loans(customer_id=customer_id) - @classmethod - def get_customer_active_loans(cls, customer_id): - """ - Get customer's active loans by customer_id. - """ - - return Loan.get_customer_active_loans(customer_id=customer_id) - - @classmethod - def update_status(cls, loan_id, status): - """ - Update the status of the loan with the given loan_id. - """ - # Retrieve loan - return Loan.update_status(loan_id, status) - @classmethod - def update_loan_balance(cls,loan_id,amount_collected): - """ - update the loan balance after successful repayment - """ - return Loan.update_loan_balance(loan_id,amount_collected) - - @classmethod - def get_overdue_loans(cls): - """ - Get all overdue loans. - """ - return Loan.get_overdue_loans() - @classmethod - def apply_penal_to_loan(cls,loan_id,penal_charge): - return Loan.apply_penal_to_loan(loan_id,penal_charge) - @staticmethod - def _update_loan_after_collection(loan, loan_data, updated_loan, amount_collected, data, response_message): - if loan.balance is None or loan.balance <= 0: - logger.warning(f"Loan ID {loan.id} has no balance. Skipping loan update.") - updated_loan = loan.to_dict() - else: - updated_loan = LoanService.update_loan_balance(int(loan_data['debtId']), amount_collected) - updated_balance = Decimal(str(updated_loan['balance'])).quantize(Decimal('0.01')) - - if updated_balance <= Decimal('0.00'): - updated_loan = LoanService.update_status(updated_loan['debtId'], LoanStatus.REPAID) - else: - updated_loan = LoanService.update_status(updated_loan['debtId'], LoanStatus.ACTIVE_PARTIAL) - logger.info(f"Updated loan status: {updated_loan.get('status')}") - # lets update the loan repayment schedule - LoanRepaymentScheduleService.handle_schedule_updates( - updated_loan=updated_loan, - data=data, - amount_collected=amount_collected, - message=response_message, - loan_data=loan_data - ) - return updated_loan - - +from app.models import Loan, LoanCharge +from app.utils.logger import logger +from app.enums.loan_status import LoanStatus +from decimal import Decimal, ROUND_HALF_UP +from app.services.loan_repayment_schedule import LoanRepaymentScheduleService + +class LoanService: + + @classmethod + def get_loan_by_transaction_id(cls, transaction_id): + """ + Get the loan by transaction ID + """ + return Loan.get_loan_by_transaction_id(transaction_id) + @classmethod + def get_loan_by_loan_id(cls, loan_id): + """ + Get the loan by ID + """ + return Loan.get_loan_by_loan_id(loan_id) + + + @classmethod + def get_loan_by_debt_id(cls, debt_id): + """ + Get the loan by transaction ID + """ + return Loan.get_loan_by_debt_id(debt_id) + + + @classmethod + def get_loan_charge_by_debt_id(cls, debt_id): + """ + Get the loan charge by debt ID + """ + return LoanCharge.get_loan_charge_by_debt_id(debt_id) + + @classmethod + def set_disbursement_date(cls, loan_id, customer_id): + """ + Update the disbursement status of the loan with the given loan_id. + """ + return Loan.set_disbursement_date(loan_id, customer_id) + @classmethod + def set_disburse_verify_date(cls, loan_id, customer_id): + """ + Update the disburse verify date of the loan with the given loan_id. + """ + return Loan.set_disburse_verify_date(loan_id, customer_id) + + @classmethod + def set_disbursement_result(cls, loan_id, result, description): + """ + Update the disbursement result of the loan with the given loan_id. + """ + return Loan.set_disbursement_result(loan_id, result, description) + + @classmethod + def set_disbursement_loan_description(cls,loan_id,description): + + return Loan.set_disbursement_message(loan_id, description) + + + @classmethod + def set_disburse_verify_result(cls, loan_id, result, description): + """ + Update the disburse verify result of the loan with the given loan_id. + """ + return Loan.set_disburse_verify_result(loan_id, result, description) + + @classmethod + def get_latest_loan_without_disburse_date(cls): + """ + Get the latest loan without a disbursement date. + """ + return Loan.get_latest_loan_without_disburse_date() + + @classmethod + def get_latest_loan_with_disburse_date(cls): + """ + Get the latest loan without a disbursement date. + """ + return Loan.get_latest_loan_with_disburse_date() + + @classmethod + def get_customer_loans(cls, customer_id): + """ + Get customer's active loans by customer_id. + """ + + return Loan.get_customer_loans(customer_id=customer_id) + @classmethod + def get_customer_active_loans(cls, customer_id): + """ + Get customer's active loans by customer_id. + """ + + return Loan.get_customer_active_loans(customer_id=customer_id) + + @classmethod + def update_status(cls, loan_id, status): + """ + Update the status of the loan with the given loan_id. + """ + # Retrieve loan + return Loan.update_status(loan_id, status) + @classmethod + def update_loan_balance(cls,loan_id,amount_collected): + """ + update the loan balance after successful repayment + """ + return Loan.update_loan_balance(loan_id,amount_collected) + + @classmethod + def get_overdue_loans(cls): + """ + Get all overdue loans. + """ + return Loan.get_overdue_loans() + @classmethod + def apply_penal_to_loan(cls,loan_id,penal_charge): + return Loan.apply_penal_to_loan(loan_id,penal_charge) + @staticmethod + def _update_loan_after_collection(loan, loan_data, updated_loan, amount_collected, data, response_message): + if loan.balance is None or loan.balance <= 0: + logger.warning(f"Loan ID {loan.id} has no balance. Skipping loan update.") + updated_loan = loan.to_dict() + else: + updated_loan = LoanService.update_loan_balance(int(loan_data['debtId']), amount_collected) + updated_balance = Decimal(str(updated_loan['balance'])).quantize(Decimal('0.01')) + + if updated_balance <= Decimal('0.00'): + updated_loan = LoanService.update_status(updated_loan['debtId'], LoanStatus.REPAID) + else: + updated_loan = LoanService.update_status(updated_loan['debtId'], LoanStatus.ACTIVE_PARTIAL) + logger.info(f"Updated loan status: {updated_loan.get('status')}") + # lets update the loan repayment schedule + LoanRepaymentScheduleService.handle_schedule_updates( + updated_loan=updated_loan, + data=data, + amount_collected=amount_collected, + message=response_message, + loan_data=loan_data + ) + return updated_loan + + \ No newline at end of file diff --git a/app/services/loan_charge.py b/app/services/loan_charge.py index b07cb8e..eded845 100644 --- a/app/services/loan_charge.py +++ b/app/services/loan_charge.py @@ -1,13 +1,13 @@ -from app.models.loan_charge import LoanCharge - -class LoanChargesService: - @classmethod - def create_penal_charges_for_loan(cls, loan_id, transaction_id, percent, penal_no, schedule_number, penal_amount=0.0,): - return LoanCharge.create_penal_charges_for_loan(loan_id, transaction_id, percent, penal_no,schedule_number, penal_amount) - @classmethod - def get_last_penal_no(cls,loan_id): - return LoanCharge.get_last_penal_no(loan_id) - - @classmethod - def get_penal_charges_by_loan_id(cls,loan_id): +from app.models.loan_charge import LoanCharge + +class LoanChargesService: + @classmethod + def create_penal_charges_for_loan(cls, loan_id, transaction_id, percent, penal_no, schedule_number, penal_amount=0.0,): + return LoanCharge.create_penal_charges_for_loan(loan_id, transaction_id, percent, penal_no,schedule_number, penal_amount) + @classmethod + def get_last_penal_no(cls,loan_id): + return LoanCharge.get_last_penal_no(loan_id) + + @classmethod + def get_penal_charges_by_loan_id(cls,loan_id): return LoanCharge.get_penal_charges_by_loan_id(loan_id) \ No newline at end of file diff --git a/app/services/loan_repayment_schedule.py b/app/services/loan_repayment_schedule.py index de3f4e6..b573eaa 100644 --- a/app/services/loan_repayment_schedule.py +++ b/app/services/loan_repayment_schedule.py @@ -1,136 +1,136 @@ -from app.models.loan_repayment_schedule import LoanRepaymentSchedule -from app.utils.logger import logger -from app.enums.loan_status import LoanStatus -from decimal import Decimal, ROUND_HALF_UP - - -class LoanRepaymentScheduleService: - - @classmethod - def get_repayment_schedule_by_loan_id(cls, loan_id, include_paid=True): - return LoanRepaymentSchedule.get_repayment_schedule_by_loan_id(loan_id, include_paid=include_paid) - @classmethod - def get_overdue_repayment_schedule(cls): - return LoanRepaymentSchedule.get_overdue_repayment_schedule() - @classmethod - def get_active_overdue_repayment_schedule(cls): - return LoanRepaymentSchedule.get_active_overdue_repayment_schedule() - @classmethod - def get_partially_paid_overdue_repayment_schedule(cls): - return LoanRepaymentSchedule.get_partially_paid_overdue_repayment_schedule() - @classmethod - def get_repayment_schedule_by_id_and_transaction_id(cls, id, transaction_id): - return LoanRepaymentSchedule.get_repayment_schedule_by_id_and_transaction_id(id, transaction_id) - - @classmethod - def get_repayment_schedule_by_transaction_id(cls, transaction_id): - return LoanRepaymentSchedule.get_repayment_schedule_by_transaction_id(transaction_id) - - @classmethod - def update_repayment_schedule_status(cls, schedule_id): - """ - Update repayment schedule status. - """ - return LoanRepaymentSchedule.update_repayment_schedule_status(schedule_id) - @classmethod - def update_repayment_schedule_status_to_active(cls, schedule_id): - """ - Update repayment schedule status. - """ - return LoanRepaymentSchedule.update_repayment_schedule_status_to_active(schedule_id) - @classmethod - def update_repayment_schedule_balance(cls, schedule_id, amount_collected): - """ - Update repayment schedule balance. - """ - return LoanRepaymentSchedule.update_repayment_schedule_balance(schedule_id, amount_collected) - @classmethod - def calculate_penal_charge(cls, schedule): - """ - Calculate penal charge for a repayment schedule. - """ - return LoanRepaymentSchedule.calculate_penal_charge(schedule) - - @classmethod - def update_repayment_schedule_description(cls, schedule_id, description): - """ - Update repayment schedule description. - """ - return LoanRepaymentSchedule.update_repayment_schedule_description(schedule_id, description) - - @classmethod - def get_overdue_repayment_schedule_with_grace_period(cls, grace_period_days, limit=None): - return LoanRepaymentSchedule.get_overdue_repayment_schedule_with_grace_period(grace_period_days, limit=limit) - - @classmethod - def apply_penal_to_schedule(cls, schedule_id, penal_amount): - """ - Apply penal charge to a repayment schedule. - """ - return LoanRepaymentSchedule.apply_penal_to_schedule(schedule_id, penal_amount) - @staticmethod - def handle_schedule_updates(updated_loan, data, amount_collected, message, loan_data): - """ - Handles updating loan repayment schedules depending on loan status - and overdue schedule data. - """ - try: - # Case 1: Loan fully repaid → mark all schedules paid - if updated_loan and updated_loan.get('status') == LoanStatus.REPAID: - repayment_schedule = LoanRepaymentScheduleService.get_repayment_schedule_by_loan_id( - updated_loan['debtId'], include_paid=False - ) - logger.info(f'Loan repayment schedule: {repayment_schedule}') - - if repayment_schedule: - for installment in repayment_schedule: - try: - logger.info(f'Processing installment: {installment}') - LoanRepaymentScheduleService.update_repayment_schedule_status(installment.id) - LoanRepaymentScheduleService.update_repayment_schedule_description( - installment.id, - message - ) - logger.info(f'Updated installment {installment.id} as paid') - except Exception as e: - logger.error(f"Failed to update installment {installment.id}: {e}") - logger.info('All installments processed') - - # Case 2: Partial repayment made on a full loan without overdueLoanScheduleId - elif updated_loan and updated_loan.get('status') == LoanStatus.ACTIVE_PARTIAL and not data.get('overdueLoanScheduleId'): - logger.info("Partial repayment detected, but no overdue schedule ID provided.") - # TODO: implement proportional installment updates - - # Case 3: when we are processing Overdue schedule repayment → update balance & description - elif data.get('overdueLoanScheduleId') is not None: - logger.info(f"Overdue loan schedule ID: {data['overdueLoanScheduleId']}") - try: - schedule_to_update = LoanRepaymentScheduleService.get_repayment_schedule_by_id_and_transaction_id( - data["overdueLoanScheduleId"], data["transactionId"] - ) - logger.info(f"Schedule to update: {schedule_to_update}") - - if schedule_to_update is None: - logger.warning( - f"Repayment schedule not found for ID {data['overdueLoanScheduleId']} " - f"and transaction ID {loan_data['transactionId']}" - ) - else: - if not schedule_to_update.paid: - update_schedule_balance = LoanRepaymentScheduleService.update_repayment_schedule_balance( - schedule_to_update.id, amount_collected - ) - logger.info(f"Updated loan schedule balance: {update_schedule_balance}") - LoanRepaymentScheduleService.update_repayment_schedule_description( - schedule_to_update.id, - message - ) - except Exception as e: - logger.error(f"Failed to update repayment schedule installment: {e}") - - except Exception as e: - logger.error(f"Unexpected error while handling schedule updates: {e}") - - - - +from app.models.loan_repayment_schedule import LoanRepaymentSchedule +from app.utils.logger import logger +from app.enums.loan_status import LoanStatus +from decimal import Decimal, ROUND_HALF_UP + + +class LoanRepaymentScheduleService: + + @classmethod + def get_repayment_schedule_by_loan_id(cls, loan_id, include_paid=True): + return LoanRepaymentSchedule.get_repayment_schedule_by_loan_id(loan_id, include_paid=include_paid) + @classmethod + def get_overdue_repayment_schedule(cls): + return LoanRepaymentSchedule.get_overdue_repayment_schedule() + @classmethod + def get_active_overdue_repayment_schedule(cls): + return LoanRepaymentSchedule.get_active_overdue_repayment_schedule() + @classmethod + def get_partially_paid_overdue_repayment_schedule(cls): + return LoanRepaymentSchedule.get_partially_paid_overdue_repayment_schedule() + @classmethod + def get_repayment_schedule_by_id_and_transaction_id(cls, id, transaction_id): + return LoanRepaymentSchedule.get_repayment_schedule_by_id_and_transaction_id(id, transaction_id) + + @classmethod + def get_repayment_schedule_by_transaction_id(cls, transaction_id): + return LoanRepaymentSchedule.get_repayment_schedule_by_transaction_id(transaction_id) + + @classmethod + def update_repayment_schedule_status(cls, schedule_id): + """ + Update repayment schedule status. + """ + return LoanRepaymentSchedule.update_repayment_schedule_status(schedule_id) + @classmethod + def update_repayment_schedule_status_to_active(cls, schedule_id): + """ + Update repayment schedule status. + """ + return LoanRepaymentSchedule.update_repayment_schedule_status_to_active(schedule_id) + @classmethod + def update_repayment_schedule_balance(cls, schedule_id, amount_collected): + """ + Update repayment schedule balance. + """ + return LoanRepaymentSchedule.update_repayment_schedule_balance(schedule_id, amount_collected) + @classmethod + def calculate_penal_charge(cls, schedule): + """ + Calculate penal charge for a repayment schedule. + """ + return LoanRepaymentSchedule.calculate_penal_charge(schedule) + + @classmethod + def update_repayment_schedule_description(cls, schedule_id, description): + """ + Update repayment schedule description. + """ + return LoanRepaymentSchedule.update_repayment_schedule_description(schedule_id, description) + + @classmethod + def get_overdue_repayment_schedule_with_grace_period(cls, grace_period_days, limit=None): + return LoanRepaymentSchedule.get_overdue_repayment_schedule_with_grace_period(grace_period_days, limit=limit) + + @classmethod + def apply_penal_to_schedule(cls, schedule_id, penal_amount): + """ + Apply penal charge to a repayment schedule. + """ + return LoanRepaymentSchedule.apply_penal_to_schedule(schedule_id, penal_amount) + @staticmethod + def handle_schedule_updates(updated_loan, data, amount_collected, message, loan_data): + """ + Handles updating loan repayment schedules depending on loan status + and overdue schedule data. + """ + try: + # Case 1: Loan fully repaid → mark all schedules paid + if updated_loan and updated_loan.get('status') == LoanStatus.REPAID: + repayment_schedule = LoanRepaymentScheduleService.get_repayment_schedule_by_loan_id( + updated_loan['debtId'], include_paid=False + ) + logger.info(f'Loan repayment schedule: {repayment_schedule}') + + if repayment_schedule: + for installment in repayment_schedule: + try: + logger.info(f'Processing installment: {installment}') + LoanRepaymentScheduleService.update_repayment_schedule_status(installment.id) + LoanRepaymentScheduleService.update_repayment_schedule_description( + installment.id, + message + ) + logger.info(f'Updated installment {installment.id} as paid') + except Exception as e: + logger.error(f"Failed to update installment {installment.id}: {e}") + logger.info('All installments processed') + + # Case 2: Partial repayment made on a full loan without overdueLoanScheduleId + elif updated_loan and updated_loan.get('status') == LoanStatus.ACTIVE_PARTIAL and not data.get('overdueLoanScheduleId'): + logger.info("Partial repayment detected, but no overdue schedule ID provided.") + # TODO: implement proportional installment updates + + # Case 3: when we are processing Overdue schedule repayment → update balance & description + elif data.get('overdueLoanScheduleId') is not None: + logger.info(f"Overdue loan schedule ID: {data['overdueLoanScheduleId']}") + try: + schedule_to_update = LoanRepaymentScheduleService.get_repayment_schedule_by_id_and_transaction_id( + data["overdueLoanScheduleId"], data["transactionId"] + ) + logger.info(f"Schedule to update: {schedule_to_update}") + + if schedule_to_update is None: + logger.warning( + f"Repayment schedule not found for ID {data['overdueLoanScheduleId']} " + f"and transaction ID {loan_data['transactionId']}" + ) + else: + if not schedule_to_update.paid: + update_schedule_balance = LoanRepaymentScheduleService.update_repayment_schedule_balance( + schedule_to_update.id, amount_collected + ) + logger.info(f"Updated loan schedule balance: {update_schedule_balance}") + LoanRepaymentScheduleService.update_repayment_schedule_description( + schedule_to_update.id, + message + ) + except Exception as e: + logger.error(f"Failed to update repayment schedule installment: {e}") + + except Exception as e: + logger.error(f"Unexpected error while handling schedule updates: {e}") + + + + diff --git a/app/services/repayment.py b/app/services/repayment.py index 2ba7f7f..5676378 100644 --- a/app/services/repayment.py +++ b/app/services/repayment.py @@ -1,78 +1,78 @@ -from app.models import Repayment - -class RepaymentService: - - @staticmethod - def get_repayment_by_transaction_id(transaction_id): - """ - Get the repayment by transaction ID - """ - return Repayment.get_repayment_by_transaction_id(transaction_id) - @staticmethod - def get_repayment_by_id(id): - """ - Get the repayment by ID - """ - return Repayment.get_repayment_by_id(id) - - @classmethod - def set_repay_date(cls, repayment_id, customer_id): - """ - Update the repay status of the repayment with the given repayment_id. - """ - return Repayment.set_repay_date(repayment_id, customer_id) - - @classmethod - def set_repay_verify_date(cls, repayment_id, customer_id): - """ - Update the verify date of the repayment with the given repayment_id. - """ - return Repayment.set_repay_verify_date(repayment_id, customer_id) - - @classmethod - def set_repay_result(cls, repayment_id, result, description): - """ - Update the repay result of the repayment with the given repayment_id. - """ - return Repayment.set_repay_result(repayment_id, result, description) - - @classmethod - def set_verify_date_result(cls, repayment_id, result, description): - """ - Update the verify result of the repayment with the given repayment_id. - """ - return Repayment.set_verify_date_result(repayment_id, result, description) - - @classmethod - def get_latest_repayment_without_repay_date(cls): - """ - Get the latest repayment without a repay date. - """ - return Repayment.get_latest_repayment_without_repay_date() - @classmethod - def get_latest_repayment_with_loanId(cls,loan_id): - """ - Get the latest repayment with loan id. - """ - return Repayment.get_latest_repayment_with_loanId(loan_id) - - @classmethod - def get_latest_loan_with_repay_date(cls): - """ - Get the latest repayment with a repay date and no verification date. - """ - return Repayment.get_latest_loan_with_repay_date() - - @classmethod - def add_repayment(cls, data): - """ - Add a new repayment entry. - """ - return Repayment.add_repayment(data) - - @classmethod - def create_repayment(cls, repayment_data): - """ - Add a new repayment entry. - """ +from app.models import Repayment + +class RepaymentService: + + @staticmethod + def get_repayment_by_transaction_id(transaction_id): + """ + Get the repayment by transaction ID + """ + return Repayment.get_repayment_by_transaction_id(transaction_id) + @staticmethod + def get_repayment_by_id(id): + """ + Get the repayment by ID + """ + return Repayment.get_repayment_by_id(id) + + @classmethod + def set_repay_date(cls, repayment_id, customer_id): + """ + Update the repay status of the repayment with the given repayment_id. + """ + return Repayment.set_repay_date(repayment_id, customer_id) + + @classmethod + def set_repay_verify_date(cls, repayment_id, customer_id): + """ + Update the verify date of the repayment with the given repayment_id. + """ + return Repayment.set_repay_verify_date(repayment_id, customer_id) + + @classmethod + def set_repay_result(cls, repayment_id, result, description): + """ + Update the repay result of the repayment with the given repayment_id. + """ + return Repayment.set_repay_result(repayment_id, result, description) + + @classmethod + def set_verify_date_result(cls, repayment_id, result, description): + """ + Update the verify result of the repayment with the given repayment_id. + """ + return Repayment.set_verify_date_result(repayment_id, result, description) + + @classmethod + def get_latest_repayment_without_repay_date(cls): + """ + Get the latest repayment without a repay date. + """ + return Repayment.get_latest_repayment_without_repay_date() + @classmethod + def get_latest_repayment_with_loanId(cls,loan_id): + """ + Get the latest repayment with loan id. + """ + return Repayment.get_latest_repayment_with_loanId(loan_id) + + @classmethod + def get_latest_loan_with_repay_date(cls): + """ + Get the latest repayment with a repay date and no verification date. + """ + return Repayment.get_latest_loan_with_repay_date() + + @classmethod + def add_repayment(cls, data): + """ + Add a new repayment entry. + """ + return Repayment.add_repayment(data) + + @classmethod + def create_repayment(cls, repayment_data): + """ + Add a new repayment entry. + """ return Repayment.create_repayment(repayment_data) \ No newline at end of file diff --git a/app/services/repayments_data.py b/app/services/repayments_data.py index 424b279..0c0af31 100644 --- a/app/services/repayments_data.py +++ b/app/services/repayments_data.py @@ -1,11 +1,11 @@ -from app.models import RepaymentsData - -class RepaymentService: - - - @classmethod - def add_repayment_data(cls,data): - """ - Add a new repayment data entry. - """ +from app.models import RepaymentsData + +class RepaymentService: + + + @classmethod + def add_repayment_data(cls,data): + """ + Add a new repayment data entry. + """ return RepaymentsData.add_repayment_data(data) \ No newline at end of file diff --git a/app/services/salary.py b/app/services/salary.py index f85e9af..5a39919 100644 --- a/app/services/salary.py +++ b/app/services/salary.py @@ -1,24 +1,24 @@ -from app.models import Salary - -class SalaryService: - - - @classmethod - def add_salary_data(cls,data): - """ - Add a new salary data entry. - """ - return Salary.add_salary_data(data) - - @classmethod - def get_pending_salaries(cls): - """ - Get the pending salary for a given customer. - """ - return Salary.get_pending_salaries() - @classmethod - def update_status(cls, salary_id, status): - """ - Update the status of the salary with the given salary_id. - """ +from app.models import Salary + +class SalaryService: + + + @classmethod + def add_salary_data(cls,data): + """ + Add a new salary data entry. + """ + return Salary.add_salary_data(data) + + @classmethod + def get_pending_salaries(cls): + """ + Get the pending salary for a given customer. + """ + return Salary.get_pending_salaries() + @classmethod + def update_status(cls, salary_id, status): + """ + Update the status of the salary with the given salary_id. + """ return Salary.update_status(salary_id, status) \ No newline at end of file diff --git a/app/services/transactions.py b/app/services/transactions.py index ff42c33..0768d12 100644 --- a/app/services/transactions.py +++ b/app/services/transactions.py @@ -1,17 +1,17 @@ -from app.models import Transaction - -class TransactionService: - - @staticmethod - def get_transaction_by_transaction_id(transaction_id): - """ - Get the transaction by ID - """ - return Transaction.get_transaction_by_transaction_id(transaction_id) - - @staticmethod - def create_transaction(transaction_id, account_id, customer_id, type, channel): - """ - Create Transaction Entry - """ - return Transaction.create_transaction(transaction_id, account_id, customer_id, type, channel) +from app.models import Transaction + +class TransactionService: + + @staticmethod + def get_transaction_by_transaction_id(transaction_id): + """ + Get the transaction by ID + """ + return Transaction.get_transaction_by_transaction_id(transaction_id) + + @staticmethod + def create_transaction(transaction_id, account_id, customer_id, type, channel): + """ + Create Transaction Entry + """ + return Transaction.create_transaction(transaction_id, account_id, customer_id, type, channel) diff --git a/app/utils/auth.py b/app/utils/auth.py index 2b7769e..34a1bb5 100644 --- a/app/utils/auth.py +++ b/app/utils/auth.py @@ -1,45 +1,45 @@ -from app.config import settings -import requests -from app.utils.logger import logger - -def get_headers(): - BANK_CALL_BASE_URL = settings.BANK_CALL_BASE_URL - BANK_CALL_AUTH_ENDPOINT = settings.BANK_CALL_AUTH_ENDPOINT - BANK_CALL_BASIC_AUTH_USERNAME = settings.BANK_CALL_BASIC_AUTH_USERNAME - BANK_CALL_BASIC_AUTH_PASSWORD = settings.BANK_CALL_BASIC_AUTH_PASSWORD - BANK_GRANT_TYPE = settings.BANK_GRANT_TYPE - #authenticate - url = f"{BANK_CALL_BASE_URL}{BANK_CALL_AUTH_ENDPOINT}" - data = { - "grant_type": BANK_GRANT_TYPE, - "username": BANK_CALL_BASIC_AUTH_USERNAME, - "password": "G7$k9@pL2!qR" - } - logger.info(f"Calling Bank Call-Auth Endpoint: {url}") - - headers = {"Content-Type": "application/json"} - - try: - response = requests.post(url, json=data, headers=headers, timeout=10) - response.raise_for_status() # Raises HTTPError for 4xx/5xx - result = response.json() - - - # Check if access_token is present - if 'access_token' not in result: - logger.error("No access_token found in Bank Call Auth response") - return {"error": "Authentication failed: no access_token returned"} - - return { - "Content-Type": "application/json", - "x-api-key": settings.BANK_CALL_API_KEY, - "App-Id": settings.BANK_CALL_APP_ID, - "Authorization": f"Bearer {result['access_token']}" - } - - except requests.exceptions.RequestException as e: - logger.error(f"Failed to get auth token: {e}") - raise - except ValueError as e: - logger.error(f"Failed to parse auth response JSON: {e}") - raise +from app.config import settings +import requests +from app.utils.logger import logger + +def get_headers(): + BANK_CALL_BASE_URL = settings.BANK_CALL_BASE_URL + BANK_CALL_AUTH_ENDPOINT = settings.BANK_CALL_AUTH_ENDPOINT + BANK_CALL_BASIC_AUTH_USERNAME = settings.BANK_CALL_BASIC_AUTH_USERNAME + BANK_CALL_BASIC_AUTH_PASSWORD = settings.BANK_CALL_BASIC_AUTH_PASSWORD + BANK_GRANT_TYPE = settings.BANK_GRANT_TYPE + #authenticate + url = f"{BANK_CALL_BASE_URL}{BANK_CALL_AUTH_ENDPOINT}" + data = { + "grant_type": BANK_GRANT_TYPE, + "username": BANK_CALL_BASIC_AUTH_USERNAME, + "password": "G7$k9@pL2!qR" + } + logger.info(f"Calling Bank Call-Auth Endpoint: {url}") + + headers = {"Content-Type": "application/json"} + + try: + response = requests.post(url, json=data, headers=headers, timeout=10) + response.raise_for_status() # Raises HTTPError for 4xx/5xx + result = response.json() + + + # Check if access_token is present + if 'access_token' not in result: + logger.error("No access_token found in Bank Call Auth response") + return {"error": "Authentication failed: no access_token returned"} + + return { + "Content-Type": "application/json", + "x-api-key": settings.BANK_CALL_API_KEY, + "App-Id": settings.BANK_CALL_APP_ID, + "Authorization": f"Bearer {result['access_token']}" + } + + except requests.exceptions.RequestException as e: + logger.error(f"Failed to get auth token: {e}") + raise + except ValueError as e: + logger.error(f"Failed to parse auth response JSON: {e}") + raise diff --git a/app/utils/extras.py b/app/utils/extras.py index effc889..bd5ae1c 100644 --- a/app/utils/extras.py +++ b/app/utils/extras.py @@ -1,16 +1,16 @@ -def preprocess_loan_charges_data(data): - """ - Preprocesses the data into a dictionary for efficient lookups by 'code'. - - Args: - data: A list of dictionaries. - - Returns: - A dictionary where keys are 'code' values and values are the corresponding dictionaries from the input data. - If multiple items have the same code, the last one encountered will be stored. - """ - preprocessed = {} - for item in data: - if 'code' in item: - preprocessed[item['code']] = item +def preprocess_loan_charges_data(data): + """ + Preprocesses the data into a dictionary for efficient lookups by 'code'. + + Args: + data: A list of dictionaries. + + Returns: + A dictionary where keys are 'code' values and values are the corresponding dictionaries from the input data. + If multiple items have the same code, the last one encountered will be stored. + """ + preprocessed = {} + for item in data: + if 'code' in item: + preprocessed[item['code']] = item return preprocessed \ No newline at end of file diff --git a/app/utils/logger.py b/app/utils/logger.py index ecf9380..9d5fecc 100644 --- a/app/utils/logger.py +++ b/app/utils/logger.py @@ -1,13 +1,13 @@ -import logging - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(levelname)s - %(message)s", - handlers=[ - # logging.StreamHandler(), # Log to console - logging.FileHandler("app.log", mode='a') # Log to file - ] -) - -logger = logging.getLogger("DetectionService") +import logging + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + handlers=[ + # logging.StreamHandler(), # Log to console + logging.FileHandler("app.log", mode='a') # Log to file + ] +) + +logger = logging.getLogger("DetectionService") diff --git a/app/utils/mail.py b/app/utils/mail.py index 3e39255..41dbcf7 100644 --- a/app/utils/mail.py +++ b/app/utils/mail.py @@ -1,40 +1,40 @@ -from flask_mail import Message -from flask import current_app -from app.extensions import mail -import pandas as pd -from io import BytesIO - -def get_report_data(): - """ - Fetch and return loan summary data. - """ - return [ - {"Type": "Disbursement", "Count": 45}, - {"Type": "Repayment", "Count": 32}, - ] -def send_report_email(report_data: list, recipients: list): - """ - Sends an HTML + Excel report to the given email recipients. - """ - df = pd.DataFrame(report_data) - output = BytesIO() - df.to_excel(output, index=False) - output.seek(0) - - html_table = df.to_html(index=False, border=1) - - msg = Message( - subject="Loan Report Summary", - recipients=recipients, - html=f"

Loan Report Summary

{html_table}", - ) - msg.attach( - "loan_report.xlsx", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - output.read() - ) - - with current_app.app_context(): - mail.send(msg) - +from flask_mail import Message +from flask import current_app +from app.extensions import mail +import pandas as pd +from io import BytesIO + +def get_report_data(): + """ + Fetch and return loan summary data. + """ + return [ + {"Type": "Disbursement", "Count": 45}, + {"Type": "Repayment", "Count": 32}, + ] +def send_report_email(report_data: list, recipients: list): + """ + Sends an HTML + Excel report to the given email recipients. + """ + df = pd.DataFrame(report_data) + output = BytesIO() + df.to_excel(output, index=False) + output.seek(0) + + html_table = df.to_html(index=False, border=1) + + msg = Message( + subject="Loan Report Summary", + recipients=recipients, + html=f"

Loan Report Summary

{html_table}", + ) + msg.attach( + "loan_report.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + output.read() + ) + + with current_app.app_context(): + mail.send(msg) + return "Report email sent" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 9189c69..f3da6c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,32 +1,32 @@ -version: "3.8" - -services: - flask: - build: . - env_file: - - .env - ports: - - "5000:5000" - environment: - - FLASK_APP=app.py - - FLASK_RUN_HOST=0.0.0.0 - volumes: - - .:/app - restart: always - networks: - - digital - - swagger: - image: swaggerapi/swagger-ui:v5.1.0 - ports: - - "9000:8080" - volumes: - - ./openapi.yml:/usr/local/openapi.yml - environment: - - SWAGGER_JSON=/usr/local/openapi.yml - restart: always - networks: - - digital -networks: - digital: - driver: bridge +version: "3.8" + +services: + flask: + build: . + env_file: + - .env + ports: + - "5000:5000" + environment: + - FLASK_APP=app.py + - FLASK_RUN_HOST=0.0.0.0 + volumes: + - .:/app + restart: always + networks: + - digital + + swagger: + image: swaggerapi/swagger-ui:v5.1.0 + ports: + - "9000:8080" + volumes: + - ./openapi.yml:/usr/local/openapi.yml + environment: + - SWAGGER_JSON=/usr/local/openapi.yml + restart: always + networks: + - digital +networks: + digital: + driver: bridge diff --git a/openapi.yml b/openapi.yml index 4434beb..a0dbba7 100644 --- a/openapi.yml +++ b/openapi.yml @@ -1,259 +1,259 @@ -openapi: 3.0.3 -info: - title: Event Manager API - description: The documentation for Event Manager API - version: 1.0.0 - contact: - name: API Support - email: support@example.com - license: - name: MIT - url: https://opensource.org/licenses/MIT - -servers: - - url: http://localhost:5000 - description: Local development server - - url: http://www.simbrellang.net:5000 - description: Remote Temporary development server - - url: https://event-core.simbrellang.net - description: Remote development server - - url: http://10.2.249.133:5000 - description: Internal development server - -paths: - /health: - get: - summary: Returns a health message - responses: - 200: - description: A successful response - /status-call: - post: - summary: Perform a status call - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - requestId: - type: string - example: "R02802" - countryCode: - type: string - example: "NGR" - transactionId: - type: string - example: "Tr201712RK9232P115" - debtId: - type: string - example: "173021" - transactionType: - type: string - example: "Disbursement" - customerId: - type: string - example: "CN621868" - responses: - 200: - description: A successful response - /sms: - post: - summary: Send a SMS - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - text: - type: string - example: "This is a test message for SMS request method." - dest: - type: string - example: "+2348039409144" - unicode: - type: boolean - example: false - responses: - 200: - description: A successful response - /bulk-sms: - post: - summary: Send a bulk SMS - requestBody: - required: true - content: - application/json: - schema: - type: array - items: - type: object - properties: - text: - type: string - example: "This is a test message for SMS request method." - dest: - type: string - example: "+2348039409144" - unicode: - type: boolean - example: true - responses: - 200: - description: A successful response - /autocall/refresh-verify-disbursement: - get: - summary: Refresh the disbursement to verify - responses: - 200: - description: A successful response - /autocall/refresh-disbursement: - get: - summary: Refresh the disbursement - responses: - 200: - description: A successful response - /autocall/refresh-verify-collection: - get: - summary: Refresh the disbursement to verify - responses: - 200: - description: A successful response - /autocall/refresh-collection: - get: - summary: Refresh the disbursement - responses: - 200: - description: A successful response - /autocall/payment-callback: - get: - summary: The Payment callback - responses: - 200: - description: A successful response - /autocall/penal-charge: - post: - summary: Penal Charge Request - requestBody: - required: true - content: - application/json: - schema: - type: array - items: - type: object - properties: - transactionId: - type: string - example: "T004" - fbnTransactionId: - type: string - example: "Tr201712RK9232P115" - debtId: - type: string - example: "273194670" - customerId: - type: string - example: "CN621868" - accountId: - type: string - example: "2017821799" - penalCharge: - type: number - example: "1.2" - lienAmount: - type: number - example: "101.2" - countryId: - type: string - example: "01" - comment: - type: string - example: "Testing PenalCharge" - responses: - 200: - description: A successful response - /autocall/analytic-salary-detect: - post: - summary: Salary Detect Endpoint - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - customerId: - type: string - example: "CN621868" - accountId: - type: string - example: "OP621868" - status: - type: string - salaryAmount: - type: number - example: 200000 - salaryDate: - type: string - example: "2025-01-01" - responses: - 200: - description: A successful response - - /autocall/report: - get: - summary: Generate and send a report - responses: - 200: - description: A successful response - /autocall/overdue-loans: - get: - summary: Get all overdue loans - responses: - 200: - description: A successful response - /autocall/process-penal-charges: - get: - summary: Get all overdue loans with grace period - responses: - 200: - description: A successful response - /autocall/direct/loan: - post: - summary: Direct call for loan disbursement - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - transactionId: - type: string - example: "TXN123456" - responses: - 200: - description: A successful response - /autocall/direct/repayment: - post: - summary: Direct call for loan repayment - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - transactionId: - type: string - example: "TXN123456" - - responses: - 200: - description: A successful response - +openapi: 3.0.3 +info: + title: Event Manager API + description: The documentation for Event Manager API + version: 1.0.0 + contact: + name: API Support + email: support@example.com + license: + name: MIT + url: https://opensource.org/licenses/MIT + +servers: + - url: http://localhost:5000 + description: Local development server + - url: http://www.simbrellang.net:5000 + description: Remote Temporary development server + - url: https://event-core.simbrellang.net + description: Remote development server + - url: http://10.2.249.133:5000 + description: Internal development server + +paths: + /health: + get: + summary: Returns a health message + responses: + 200: + description: A successful response + /status-call: + post: + summary: Perform a status call + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + requestId: + type: string + example: "R02802" + countryCode: + type: string + example: "NGR" + transactionId: + type: string + example: "Tr201712RK9232P115" + debtId: + type: string + example: "173021" + transactionType: + type: string + example: "Disbursement" + customerId: + type: string + example: "CN621868" + responses: + 200: + description: A successful response + /sms: + post: + summary: Send a SMS + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + text: + type: string + example: "This is a test message for SMS request method." + dest: + type: string + example: "+2348039409144" + unicode: + type: boolean + example: false + responses: + 200: + description: A successful response + /bulk-sms: + post: + summary: Send a bulk SMS + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + type: object + properties: + text: + type: string + example: "This is a test message for SMS request method." + dest: + type: string + example: "+2348039409144" + unicode: + type: boolean + example: true + responses: + 200: + description: A successful response + /autocall/refresh-verify-disbursement: + get: + summary: Refresh the disbursement to verify + responses: + 200: + description: A successful response + /autocall/refresh-disbursement: + get: + summary: Refresh the disbursement + responses: + 200: + description: A successful response + /autocall/refresh-verify-collection: + get: + summary: Refresh the disbursement to verify + responses: + 200: + description: A successful response + /autocall/refresh-collection: + get: + summary: Refresh the disbursement + responses: + 200: + description: A successful response + /autocall/payment-callback: + get: + summary: The Payment callback + responses: + 200: + description: A successful response + /autocall/penal-charge: + post: + summary: Penal Charge Request + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + type: object + properties: + transactionId: + type: string + example: "T004" + fbnTransactionId: + type: string + example: "Tr201712RK9232P115" + debtId: + type: string + example: "273194670" + customerId: + type: string + example: "CN621868" + accountId: + type: string + example: "2017821799" + penalCharge: + type: number + example: "1.2" + lienAmount: + type: number + example: "101.2" + countryId: + type: string + example: "01" + comment: + type: string + example: "Testing PenalCharge" + responses: + 200: + description: A successful response + /autocall/analytic-salary-detect: + post: + summary: Salary Detect Endpoint + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + customerId: + type: string + example: "CN621868" + accountId: + type: string + example: "OP621868" + status: + type: string + salaryAmount: + type: number + example: 200000 + salaryDate: + type: string + example: "2025-01-01" + responses: + 200: + description: A successful response + + /autocall/report: + get: + summary: Generate and send a report + responses: + 200: + description: A successful response + /autocall/overdue-loans: + get: + summary: Get all overdue loans + responses: + 200: + description: A successful response + /autocall/process-penal-charges: + get: + summary: Get all overdue loans with grace period + responses: + 200: + description: A successful response + /autocall/direct/loan: + post: + summary: Direct call for loan disbursement + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + transactionId: + type: string + example: "TXN123456" + responses: + 200: + description: A successful response + /autocall/direct/repayment: + post: + summary: Direct call for loan repayment + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + transactionId: + type: string + example: "TXN123456" + + responses: + 200: + description: A successful response + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 25b198e..8a35637 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,16 @@ -# Flask and Extensions -Flask==2.3.3 -Flask-Marshmallow==0.15.0 -marshmallow==3.19.0 -Flask-Cors==3.0.10 -gunicorn -requests -confluent-kafka==1.9.2 -flask-sqlalchemy -psycopg2-binary -alembic -python-dateutil -oracledb -Flask-Mail==0.10.0 -pandas==2.1.3 -openpyxl==3.1.5 +# Flask and Extensions +Flask==2.3.3 +Flask-Marshmallow==0.15.0 +marshmallow==3.19.0 +Flask-Cors==3.0.10 +gunicorn +requests +confluent-kafka==1.9.2 +flask-sqlalchemy +psycopg2-binary +alembic +python-dateutil +oracledb +Flask-Mail==0.10.0 +pandas==2.1.3 +openpyxl==3.1.5 diff --git a/wsgi.py b/wsgi.py index fc10e89..0cc695a 100644 --- a/wsgi.py +++ b/wsgi.py @@ -1,37 +1,37 @@ -import threading -from app import create_app -from app.integrations import KafkaIntegration -from app.config import settings -from app.utils.logger import logger - -app = create_app() -kafka = KafkaIntegration() - -def start_kafka_consumer(app): - with app.app_context(): - logger.info("Starting Kafka consumer...") - while True: - try: - - message = kafka.receive_messages( - topics=settings.KAFKA_TOPICS, timeout=settings.KAFKA_TIMEOUT - ) - - if message: - logger.info(f"Processed message: {message}") - else: - logger.info("No message received within timeout") - - - except Exception as e: - logger.error(f"Error while receiving message: {e}") - - - -if __name__ != "__main__": - - # Expose WSGI app instance for Gunicorn - wsgi_app = app - - # Start kafka in a thread +import threading +from app import create_app +from app.integrations import KafkaIntegration +from app.config import settings +from app.utils.logger import logger + +app = create_app() +kafka = KafkaIntegration() + +def start_kafka_consumer(app): + with app.app_context(): + logger.info("Starting Kafka consumer...") + while True: + try: + + message = kafka.receive_messages( + topics=settings.KAFKA_TOPICS, timeout=settings.KAFKA_TIMEOUT + ) + + if message: + logger.info(f"Processed message: {message}") + else: + logger.info("No message received within timeout") + + + except Exception as e: + logger.error(f"Error while receiving message: {e}") + + + +if __name__ != "__main__": + + # Expose WSGI app instance for Gunicorn + wsgi_app = app + + # Start kafka in a thread threading.Thread(target=start_kafka_consumer, args=(app,), daemon=True).start() \ No newline at end of file -- 2.34.1 From 93d2659462d63d872cb208b68283a8b9be50a1d2 Mon Sep 17 00:00:00 2001 From: Chinenye Nmoh Date: Tue, 17 Mar 2026 09:42:18 +0100 Subject: [PATCH 2/2] added extra query --- app/models/loan_repayment_schedule.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/loan_repayment_schedule.py b/app/models/loan_repayment_schedule.py index 17800ce..e55a933 100644 --- a/app/models/loan_repayment_schedule.py +++ b/app/models/loan_repayment_schedule.py @@ -327,6 +327,7 @@ class LoanRepaymentSchedule(db.Model): schedule.updated_at = now db.session.commit() + # Calculate penal charge @classmethod def calculate_penal_charge(cls, schedule): -- 2.34.1