88 Commits

Author SHA1 Message Date
Chinenye Nmoh 93d2659462 added extra query 2026-03-17 09:42:18 +01:00
Chinenye Nmoh 569d4c45d7 added extra query 2026-03-17 09:37:08 +01:00
ameye bfb35e0285 Merge branch 'test' of DigiFi/digifi-EventManager into master 2026-03-12 12:56:50 +00:00
Chinenye Nmoh 1a0e1f324a added penal charge to schedule 2026-03-12 12:50:25 +01:00
CHIEFSOFT\ameye 6593aedc56 PENAL_CHARGE_MAXIMUM_COUNT 2026-03-11 06:43:43 -04:00
CHIEFSOFT\ameye 818f968935 PENAL_CHARGE_INTERVAL_DAYS 2026-03-11 06:22:53 -04:00
CHIEFSOFT\ameye 84648d8242 New penal charge columns 2026-03-11 06:12:07 -04:00
ameye 80a41d5ee1 Merge branch 'test' of DigiFi/digifi-EventManager into master 2026-03-11 09:46:57 +00:00
Chinenye Nmoh cf3a96ad98 added penal charge log 2026-03-11 10:25:03 +01:00
ameye d0dccbf1ec Merge branch 'test' of DigiFi/digifi-EventManager into master 2026-01-30 09:09:20 +00:00
Chinenye Nmoh 5c8ffc5bbc corrected interest charges on 3 months loan and made loan schedule active by default 2026-01-29 22:11:59 +01:00
ameye 9d7c3cfb32 Merge branch 'test' of DigiFi/digifi-EventManager into master 2026-01-24 01:21:50 +00:00
Chinenye Nmoh f6f8e369c4 added logic to only send charges for 1 month loan 2026-01-23 20:42:03 +01:00
Chinenye Nmoh 39ea231fa0 added logic to only send charges for 1 month loan 2026-01-23 20:38:53 +01:00
Chinenye Nmoh da05ba0f3d Merge branch 'master' of https://gitlab.chiefsoft.net/DigiFi/digifi-EventManager into test
pulled changes from main
2026-01-23 19:12:24 +01:00
Chinenye Nmoh 0f4455e738 added a new penal charge endpoint 2026-01-23 11:14:17 +01:00
ameye 53a843d129 Merge branch 'test' of DigiFi/digifi-EventManager into master 2026-01-23 01:04:23 +00:00
Chinenye Nmoh 03c12fd9b5 added a new penal charge endpoint 2026-01-22 22:41:06 +01:00
ameye a338441086 Merge branch 'test' of DigiFi/digifi-EventManager into master 2026-01-20 15:29:13 +00:00
Chinenye Nmoh f048dd99ba added throttling and batch size to processing overdue loans 2026-01-20 14:03:39 +01:00
ameye 46b3c856b1 Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-11-28 10:25:48 +00:00
Chinenye Nmoh 1cbb55fae6 added overdue loan methods 2025-11-24 16:52:43 +01:00
ameye 38dbb32579 Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-11-21 02:55:31 +00:00
Chinenye Nmoh af14baead5 Merge branch 'master' of https://gitlab.chiefsoft.net/DigiFi/digifi-EventManager into test
pulled master
2025-11-20 10:28:54 +01:00
Chinenye Nmoh fbc3f090a0 added logs 2025-11-20 10:23:12 +01:00
ameye 675c38462e Merge branch 'fixed_sms_destination' of DigiFi/digifi-EventManager into master 2025-11-14 09:42:10 +00:00
VivianDee b69052123a [fix]: SMS destination 2025-11-14 10:18:26 +01:00
CHIEFSOFT\ameye bea2b34265 hrttp error handling 2025-11-10 14:58:03 -05:00
CHIEFSOFT\ameye 36ff967ae7 timeout improvement 2025-11-10 14:45:59 -05:00
CHIEFSOFT\ameye 93a6f2e733 autp pay 2025-11-09 15:54:26 -05:00
CHIEFSOFT\ameye 018a8e7c4e 2 new endpoint 2025-11-09 15:34:25 -05:00
CHIEFSOFT\ameye 8323075ff7 layered found loan 2025-11-07 18:36:18 -05:00
CHIEFSOFT\ameye 9292b9dd62 Strip extra 2025-11-04 10:04:43 -05:00
ameye 71aa4b2890 Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-11-04 14:25:35 +00:00
Chinenye Nmoh 54d676a7cc added logs 2025-11-04 15:12:35 +01:00
ameye 5247a14796 Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-11-03 15:15:41 +00:00
Chinenye Nmoh d6d1e5a7cf added loan status check on disbursement 2025-11-03 16:04:46 +01:00
ameye fbef79ba1f Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-11-03 14:34:34 +00:00
Chinenye Nmoh e7e8a751cf logged collected amount 2025-11-03 15:26:33 +01:00
Chinenye Nmoh eae8e84fab removed collected amount check 2025-11-03 14:06:32 +01:00
CHIEFSOFT\ameye 0608faffa7 More checks 2025-11-02 19:20:49 -05:00
CHIEFSOFT\ameye 6c245ab6d3 Retry 2025-11-02 19:03:50 -05:00
CHIEFSOFT\ameye 10b4deb3a5 added commit 2025-11-02 17:54:15 -05:00
CHIEFSOFT\ameye a0725ff306 from sqlalchemy import and_, or_, not_ 2025-11-02 17:46:47 -05:00
CHIEFSOFT\ameye 3602599f4a Cfeate transactions 2025-11-02 17:42:56 -05:00
CHIEFSOFT\ameye a6198a782c Verify call bug fix 2025-11-02 15:43:46 -05:00
CHIEFSOFT\ameye c88e85eda1 retry loans 2025-11-02 15:34:55 -05:00
ameye 8ffac10cd3 Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-10-30 16:58:03 +00:00
Chinenye Nmoh 417dfecf9d added new config 2025-10-30 17:18:43 +01:00
Chinenye Nmoh 5c8505f923 added new config 2025-10-30 17:17:13 +01:00
ameye 345a817271 Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-10-28 14:01:48 +00:00
Chinenye Nmoh 6768fa89ba hardcoded password 2025-10-28 14:47:19 +01:00
Chinenye Nmoh bc0a820315 hardcoded password 2025-10-28 14:46:11 +01:00
CHIEFSOFT\ameye 0e306d5a29 See changes 2025-10-28 09:33:45 -04:00
ameye 77d24a5380 Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-10-28 08:21:06 +00:00
Chinenye Nmoh 1b1f6f4a0b corrected content type 2025-10-28 07:33:01 +01:00
ameye fe6e1ed730 Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-10-27 17:26:41 +00:00
Chinenye Nmoh 01d04bf45f added auth token 2025-10-27 18:00:57 +01:00
ameye 57341c40a6 Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-10-27 16:13:53 +00:00
Chinenye Nmoh 7d7f2eb252 added auth token 2025-10-27 16:55:05 +01:00
CHIEFSOFT\ameye 5d6b3856d6 data connections 2025-10-24 07:58:25 -04:00
ameye 2f2ea0a107 Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-10-22 09:42:27 +00:00
Chinenye Nmoh 6f417614df added health check for bank 2025-10-21 21:33:29 +01:00
CHIEFSOFT\ameye b2f18efb5c http://10.2.249.133:5000 2025-10-21 07:08:23 -04:00
CHIEFSOFT\ameye 43c40cb937 http://10.2.249.133:4500 2025-10-21 07:00:07 -04:00
ameye 5376c84c1c Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-10-03 13:21:58 +00:00
Chinenye Nmoh b0f5b71dd9 added health check for database 2025-10-03 13:44:52 +01:00
ameye 90d858a3d3 Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-09-11 09:35:53 +00:00
Chinenye Nmoh eaa4529f44 added direct loan and repayment endpoint 2025-09-11 08:38:22 +01:00
ameye 97ae340965 Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-08-28 10:51:37 +00:00
Chinenye Nmoh a174340b2f overdue loans 2025-08-27 15:49:17 +01:00
Chinenye Nmoh fb460471fb added repayment_schedule 2025-08-27 12:00:42 +01:00
ameye 04a7793015 Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-08-25 10:53:10 +00:00
Chinenye Nmoh 524836f52a added repayment_schedule 2025-08-25 11:00:40 +01:00
Chinenye Nmoh cd754e5b15 added repayment_schedule 2025-08-25 10:06:35 +01:00
ameye 5269149d28 Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-08-22 10:48:59 +00:00
Chinenye Nmoh ecd488fb79 added repayment_schedule 2025-08-21 19:10:43 +01:00
ameye 3f803609a8 Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-08-21 11:16:46 +00:00
Chinenye Nmoh 97070f3fed added overdue endpoint 2025-08-20 18:08:44 +01:00
ameye d50436253a Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-07-18 19:52:35 +00:00
Chinenye Nmoh c50b69e852 added mail values in the env file 2025-07-17 12:42:22 +01:00
ameye cef23778d5 Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-07-16 11:10:15 +00:00
Chinenye Nmoh 7ac9b8c061 added mail 2025-07-15 20:25:49 +01:00
ameye ccdb44f0d5 Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-07-14 14:14:13 +00:00
Chinenye Nmoh 22ea12b3c4 resolved method not found error 2025-07-14 07:59:37 +01:00
ameye 71608091ab Merge branch 'oracle_migration' of DigiFi/digifi-EventManager into master 2025-07-11 10:27:08 +00:00
ameye 16758c3ae9 Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-07-11 10:27:02 +00:00
Chinenye Nmoh 23574a433a corrected commit error 2025-07-08 10:49:04 +01:00
49 changed files with 4527 additions and 2870 deletions
+15 -15
View File
@@ -1,16 +1,16 @@
KAFKA_TIMEOUT=1000.0 KAFKA_TIMEOUT=1000.0
KAFKA_BROKER="10.20.30.50:9092" KAFKA_BROKER="10.20.30.50:9092"
KAFKA_TOPICS=PROCESS_PAYMENT,LOAN_REPAYMENT KAFKA_TOPICS=PROCESS_PAYMENT,LOAN_REPAYMENT
# DATABASE_USER=firstadvance # DATABASE_USER=firstadvance
# DATABASE_PASSWORD=FirstAdvance! # DATABASE_PASSWORD=FirstAdvance!
# DATABASE_HOST=10.20.30.60 # DATABASE_HOST=10.20.30.60
# DATABASE_PORT=5432 # DATABASE_PORT=5432
# DATABASE_NAME=firstadvancedev # DATABASE_NAME=firstadvancedev
DATABASE_USER=system DATABASE_USER=system
DATABASE_PASSWORD=FIRSTADV_PASS DATABASE_PASSWORD=FIRSTADV_PASS
DATABASE_HOST=10.10.33.65 DATABASE_HOST=10.10.33.65
DATABASE_PORT=1521 DATABASE_PORT=1521
DATABASE_SID=FREE DATABASE_SID=FREE
+15 -15
View File
@@ -1,16 +1,16 @@
KAFKA_TIMEOUT=1000.0 KAFKA_TIMEOUT=1000.0
KAFKA_BROKER="dev-events.simbrellang.net:9085" KAFKA_BROKER="dev-events.simbrellang.net:9085"
KAFKA_TOPICS=PROCESS_PAYMENT,LOAN_REPAYMENT KAFKA_TOPICS=PROCESS_PAYMENT,LOAN_REPAYMENT
# DATABASE_USER=firstadvance # DATABASE_USER=firstadvance
# DATABASE_PASSWORD=FirstAdvance! # DATABASE_PASSWORD=FirstAdvance!
# DATABASE_HOST=dev-data.simbrellang.net # DATABASE_HOST=dev-data.simbrellang.net
# DATABASE_PORT=10532 # DATABASE_PORT=10532
# DATABASE_NAME=firstadvancedev # DATABASE_NAME=firstadvancedev
DATABASE_USER=system DATABASE_USER=system
DATABASE_PASSWORD=FIRSTADV_PASS DATABASE_PASSWORD=FIRSTADV_PASS
DATABASE_HOST=10.10.33.65 DATABASE_HOST=10.10.33.65
DATABASE_PORT=1521 DATABASE_PORT=1521
DATABASE_SID=FREE DATABASE_SID=FREE
+5 -5
View File
@@ -1,6 +1,6 @@
.vscode/ .vscode/
__pycache__/ __pycache__/
*/__pycache__/ */__pycache__/
.env .env
app.log app.log
.idea/ .idea/
+21 -21
View File
@@ -1,21 +1,21 @@
# Use Python base image # Use Python base image
FROM python:3.10 FROM python:3.10
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
# Copy files to container # Copy files to container
COPY . /app COPY . /app
# Install dependencies # Install dependencies
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Expose port 5000 # Expose port 5000
EXPOSE 5000 EXPOSE 5000
# Set environment variables # Set environment variables
ENV FLASK_APP=app.py ENV FLASK_APP=app.py
ENV FLASK_RUN_HOST=0.0.0.0 ENV FLASK_RUN_HOST=0.0.0.0
# Run the application # Run the application
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "--timeout", "120", "wsgi:wsgi_app"] CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "--timeout", "120", "wsgi:wsgi_app"]
+75 -75
View File
@@ -1,75 +1,75 @@
# Flask API with Swagger in Docker # Flask API with Swagger in Docker
This repository contains a Flask API with Swagger documentation, running inside Docker containers. This repository contains a Flask API with Swagger documentation, running inside Docker containers.
## Getting Started ## Getting Started
### Prerequisites ### Prerequisites
Ensure you have the following installed: Ensure you have the following installed:
- [Docker](https://www.docker.com/get-started) - [Docker](https://www.docker.com/get-started)
- [Docker Compose](https://docs.docker.com/compose/install/) - [Docker Compose](https://docs.docker.com/compose/install/)
### Clone the Repository ### Clone the Repository
```sh ```sh
git clone <your-repo-url> git clone <your-repo-url>
cd <your-repo-folder> cd <your-repo-folder>
``` ```
### Setup and Run the Application ### Setup and Run the Application
#### 1. Build and Start the Containers #### 1. Build and Start the Containers
```sh ```sh
docker-compose up --build -d docker-compose up --build -d
``` ```
This will: This will:
- Build the Flask API container. - Build the Flask API container.
- Start the Swagger UI container separately. - Start the Swagger UI container separately.
#### 2. Verify the Containers are Running #### 2. Verify the Containers are Running
```sh ```sh
docker ps docker ps
``` ```
#### 3. Access the API #### 3. Access the API
- The **Flask API** will be available at: - The **Flask API** will be available at:
``` ```
http://localhost:5000 http://localhost:5000
``` ```
- The **Swagger UI** will be available at: - The **Swagger UI** will be available at:
``` ```
http://localhost:9000 http://localhost:9000
``` ```
You can interact with the API documentation and test endpoints from here. You can interact with the API documentation and test endpoints from here.
## Making API Requests ## Making API Requests
You can send requests using tools like `curl` or Postman: You can send requests using tools like `curl` or Postman:
```sh ```sh
curl -X GET http://localhost:5000/hello curl -X GET http://localhost:5000/hello
``` ```
## Stopping the Application ## Stopping the Application
To stop the running containers, use: To stop the running containers, use:
```sh ```sh
docker-compose down docker-compose down
``` ```
## Troubleshooting ## Troubleshooting
- If there are permission issues, try running Docker commands with `sudo`. - If there are permission issues, try running Docker commands with `sudo`.
- If ports are in use, change them in `docker-compose.yml`. - If ports are in use, change them in `docker-compose.yml`.
- To rebuild the containers, use: - To rebuild the containers, use:
```sh ```sh
docker-compose up --build -d docker-compose up --build -d
``` ```
+7 -7
View File
@@ -1,7 +1,7 @@
from app import create_app from app import create_app
app = create_app() app = create_app()
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True) app.run(host="0.0.0.0", port=5000, debug=True)
+36 -32
View File
@@ -1,32 +1,36 @@
from flask import Flask from flask import Flask
from flask_cors import CORS from flask_mail import Mail
from app.config import Config from flask_cors import CORS
from app.routes import auth_bp, autocall_bp from app.config import Config
from app.response import (method_not_allowed, unsupported_media_type, not_found, bad_request) from app.routes import auth_bp, autocall_bp
from app.extensions import db 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""" def create_app():
app = Flask(__name__) """Factory function to create a Flask app instance"""
app = Flask(__name__)
# Load configuration
app.config.from_object(Config) # Load configuration
app.config.from_object(Config)
# Setup CORS
CORS(app) # Setup CORS
CORS(app)
# Register blueprints
app.register_blueprint(auth_bp) # Initialize Flask-Mail
app.register_blueprint(autocall_bp, url_prefix="/autocall") mail.init_app(app)
# Error Handlers # Register blueprints
app.register_error_handler(405, method_not_allowed) app.register_blueprint(auth_bp)
app.register_error_handler(415, unsupported_media_type) app.register_blueprint(autocall_bp, url_prefix="/autocall")
app.register_error_handler(404, not_found)
app.register_error_handler(400, bad_request) # Error Handlers
app.register_error_handler(405, method_not_allowed)
# Database app.register_error_handler(415, unsupported_media_type)
db.init_app(app) app.register_error_handler(404, not_found)
app.register_error_handler(400, bad_request)
return app
# Database
db.init_app(app)
return app
+83 -53
View File
@@ -1,53 +1,83 @@
import os import os
from datetime import timedelta from datetime import timedelta
class Config: class Config:
"""Base configuration for Flask app""" """Base configuration for Flask app"""
SECRET_KEY = os.getenv("SECRET_KEY", "supersecretkey") SECRET_KEY = os.getenv("SECRET_KEY", "supersecretkey")
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your_jwt_secret") JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your_jwt_secret")
DEBUG = True DEBUG = True
KAFKA_BROKER = os.getenv("KAFKA_BROKER", "dev-events.simbrellang.net:9085") 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_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) ) KAFKA_TIMEOUT = float( os.getenv("KAFKA_TIMEOUT", 1000.0) )
JWT_ACCESS_TOKEN_EXPIRES = os.getenv("JWT_ACCESS_TOKEN_EXPIRES", timedelta(hours=1)) JWT_ACCESS_TOKEN_EXPIRES = os.getenv("JWT_ACCESS_TOKEN_EXPIRES", timedelta(hours=1))
JWT_REFRESH_TOKEN_EXPIRES = os.getenv( JWT_REFRESH_TOKEN_EXPIRES = os.getenv(
"JWT_REFRESH_TOKEN_EXPIRES", timedelta(days=30) "JWT_REFRESH_TOKEN_EXPIRES", timedelta(days=30)
) )
BANK_CALL_APP_ID = os.getenv("BANK_CALL_APP_ID", "app1") 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_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 = os.environ.get(
"BANK_CALL_BASIC_AUTH_USERNAME", "user" "BANK_CALL_BASIC_AUTH_USERNAME", "simbrella"
) )
BANK_CALL_BASIC_AUTH_PASSWORD = os.environ.get( BANK_CALL_BASIC_AUTH_PASSWORD = os.environ.get(
"BANK_CALL_BASIC_AUTH_PASSWORD", "password" "BANK_CALL_BASIC_AUTH_PASSWORD", "G7$k9@pL2!qR"
) )
DATABASE_USER = os.getenv("DATABASE_USER") DATABASE_USER = os.getenv("DATABASE_USER")
DATABASE_PASSWORD = os.getenv("DATABASE_PASSWORD") DATABASE_PASSWORD = os.getenv("DATABASE_PASSWORD")
DATABASE_HOST = os.getenv("DATABASE_HOST") DATABASE_HOST = os.getenv("DATABASE_HOST")
DATABASE_NAME = os.getenv("DATABASE_NAME") DATABASE_NAME = os.getenv("DATABASE_NAME")
DATABASE_PORT = os.getenv("DATABASE_PORT", 10532) DATABASE_PORT = os.getenv("DATABASE_PORT", 10532)
DATABASE_SID = os.environ.get("DATABASE_SID", "FREE") 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})))" DNS = f"(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST={DATABASE_HOST})(PORT={DATABASE_PORT}))(CONNECT_DATA=(SID={DATABASE_SID})))"
SQLALCHEMY_DATABASE_URI = (f"oracle+oracledb://{DATABASE_USER}:{DATABASE_PASSWORD}@{DNS}") 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 = f"postgresql+psycopg2://{DATABASE_USER}:{DATABASE_PASSWORD}@{DATABASE_HOST}:{DATABASE_PORT}/{DATABASE_NAME}" SQLALCHEMY_DATABASE_URI = os.getenv("SQLALCHEMY_DATABASE_URI_FULL", SQLALCHEMY_DATABASE_URI_INTERNAL)
SQLALCHEMY_TRACK_MODIFICATIONS = False
# SQLALCHEMY_ECHO = True # SQLALCHEMY_DATABASE_URI = f"postgresql+psycopg2://{DATABASE_USER}:{DATABASE_PASSWORD}@{DATABASE_HOST}:{DATABASE_PORT}/{DATABASE_NAME}"
SQLALCHEMY_TRACK_MODIFICATIONS = False
# SQLALCHEMY_ECHO = True
BANK_CALL_BASE_URL = os.getenv("BANK_CALL_BASE_URL", "https://bank-emulator.dev.simbrellang.net/api") OVERRIDE_COLLECTION_TRANCATION_ID = int(os.getenv("OVERRIDE_COLLECTION_TRANCATION_ID", 100))
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") MAIL_SERVER = os.getenv('MAIL_SERVER','smtp.zoho.com')
BANK_CALL_TRANSACTION_VERIFY = os.getenv("BANK_CALL_TRANSACTION_VERIFY", "/TransactionVerify") MAIL_PORT = os.getenv('MAIL_PORT', 587)
TEST_NO = os.getenv("TEST_NO", "2347038224367") MAIL_USERNAME = os.getenv('MAIL_USERNAME', 'firstadvance@dynamikservices.tech')
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
settings = Config() 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()
+3 -2
View File
@@ -1,2 +1,3 @@
from .transaction_type import TransactionType from .transaction_type import TransactionType
from .loan_status import LoanStatus from .loan_status import LoanStatus
from .repayment_schedule_status import RepaymentScheduleStatus
+7 -7
View File
@@ -1,8 +1,8 @@
from enum import Enum from enum import Enum
class LoanStatus(str, Enum): class LoanStatus(str, Enum):
PENDING = "pending" PENDING = "pending"
ACTIVE = "active" ACTIVE = "active"
ACTIVE_PARTIAL = "active_partial" ACTIVE_PARTIAL = "active_partial"
START_REPAY = "start_repay" START_REPAY = "start_repay"
REPAID = "repaid" REPAID = "repaid"
+7
View File
@@ -0,0 +1,7 @@
from enum import Enum
class RepaymentScheduleStatus(str, Enum):
PARTIALLY_PAID = "partially_paid"
REPAID = "repaid"
ACTIVE = "active"
OVERDUE = "overdue"
+10 -10
View File
@@ -1,10 +1,10 @@
from enum import Enum from enum import Enum
class TransactionType(str, Enum): class TransactionType(str, Enum):
ELIGIBILITY_CHECK = "eligibility_check" ELIGIBILITY_CHECK = "eligibility_check"
CUSTOMER_CONSENT = "customer_consent" CUSTOMER_CONSENT = "customer_consent"
LOAN_STATUS = "loan_status" LOAN_STATUS = "loan_status"
NOTIFICATION_CALLBACK = "notification_callback" NOTIFICATION_CALLBACK = "notification_callback"
PROVIDE_LOAN = "provide_loan" PROVIDE_LOAN = "provide_loan"
REPAYMENT = "repayment" REPAYMENT = "repayment"
SELECT_OFFER = "select_offer" SELECT_OFFER = "select_offer"
+4 -2
View File
@@ -1,3 +1,5 @@
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_mail import Mail
mail = Mail()
db = SQLAlchemy() db = SQLAlchemy()
+63
View File
@@ -0,0 +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]
+250 -250
View File
@@ -1,251 +1,251 @@
from flask import jsonify from flask import jsonify
from typing import List, Dict, Union, Optional, Any from typing import List, Dict, Union, Optional, Any
class ResponseHelper: class ResponseHelper:
""" """
A helper class for building standardized JSON responses in Flask. A helper class for building standardized JSON responses in Flask.
""" """
@staticmethod @staticmethod
def build_response( def build_response(
status: bool, status: bool,
message: str, message: str,
data: Optional[Union[Dict, List, str]] = None, data: Optional[Union[Dict, List, str]] = None,
status_code: int = 200, status_code: int = 200,
error: Optional[Union[Dict, str]] = None, error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Build a standardized JSON response. Build a standardized JSON response.
Args: Args:
status (bool): Indicates whether the request was successful. status (bool): Indicates whether the request was successful.
message (str): A message describing the result of the request. message (str): A message describing the result of the request.
data (Optional[Union[Dict, List, str]]): The data to return in the response. data (Optional[Union[Dict, List, str]]): The data to return in the response.
status_code (int): The HTTP status code for 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. error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns: Returns:
Dict[str, Any]: A dictionary representing the JSON response. Dict[str, Any]: A dictionary representing the JSON response.
""" """
response = { response = {
"status": status, "status": status,
"statusCode": status_code, "statusCode": status_code,
"message": message, "message": message,
"data": data if data is not None else {}, "data": data if data is not None else {},
"error": error if error is not None else {}, "error": error if error is not None else {},
} }
return jsonify(response), status_code return jsonify(response), status_code
@staticmethod @staticmethod
def success( def success(
data: Optional[Union[Dict, List, str]] = None, data: Optional[Union[Dict, List, str]] = None,
message: str = "Successful", message: str = "Successful",
status_code: int = 200, status_code: int = 200,
error: Optional[Union[Dict, str]] = None, error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Return a success response. Return a success response.
Args: Args:
data (Optional[Union[Dict, List, str]]): The data to return in the response. data (Optional[Union[Dict, List, str]]): The data to return in the response.
message (str): A message describing the result of the request. message (str): A message describing the result of the request.
status_code (int): The HTTP status code for 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. error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns: Returns:
Dict[str, Any]: A dictionary representing the JSON response. Dict[str, Any]: A dictionary representing the JSON response.
""" """
return ResponseHelper.build_response(True, message, data, status_code, error) return ResponseHelper.build_response(True, message, data, status_code, error)
@staticmethod @staticmethod
def error( def error(
message: str = "An error occurred", message: str = "An error occurred",
status_code: int = 400, status_code: int = 400,
data: Optional[Union[Dict, List, str]] = None, data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None, error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Return an error response. Return an error response.
Args: Args:
message (str): A message describing the error. message (str): A message describing the error.
status_code (int): The HTTP status code for the response. status_code (int): The HTTP status code for the response.
data (Optional[Union[Dict, List, str]]): The data to return in 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. error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns: Returns:
Dict[str, Any]: A dictionary representing the JSON response. Dict[str, Any]: A dictionary representing the JSON response.
""" """
return ResponseHelper.build_response(False, message, data, status_code, error) return ResponseHelper.build_response(False, message, data, status_code, error)
@staticmethod @staticmethod
def created( def created(
data: Optional[Union[Dict, List, str]] = None, data: Optional[Union[Dict, List, str]] = None,
message: str = "Resource created successfully", message: str = "Resource created successfully",
error: Optional[Union[Dict, str]] = None, error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Return a response for a created resource. Return a response for a created resource.
Args: Args:
data (Optional[Union[Dict, List, str]]): The data to return in the response. data (Optional[Union[Dict, List, str]]): The data to return in the response.
message (str): A message describing the result of the request. message (str): A message describing the result of the request.
error (Optional[Union[Dict, str]]): Any error details to include in the response. error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns: Returns:
Dict[str, Any]: A dictionary representing the JSON response. Dict[str, Any]: A dictionary representing the JSON response.
""" """
return ResponseHelper.build_response(True, message, data, 201, error) return ResponseHelper.build_response(True, message, data, 201, error)
@staticmethod @staticmethod
def updated( def updated(
data: Optional[Union[Dict, List, str]] = None, data: Optional[Union[Dict, List, str]] = None,
message: str = "Resource updated successfully", message: str = "Resource updated successfully",
error: Optional[Union[Dict, str]] = None, error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Return a response for an updated resource. Return a response for an updated resource.
Args: Args:
data (Optional[Union[Dict, List, str]]): The data to return in the response. data (Optional[Union[Dict, List, str]]): The data to return in the response.
message (str): A message describing the result of the request. message (str): A message describing the result of the request.
error (Optional[Union[Dict, str]]): Any error details to include in the response. error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns: Returns:
Dict[str, Any]: A dictionary representing the JSON response. Dict[str, Any]: A dictionary representing the JSON response.
""" """
return ResponseHelper.build_response(True, message, data, 200, error) return ResponseHelper.build_response(True, message, data, 200, error)
@staticmethod @staticmethod
def internal_server_error( def internal_server_error(
message: str = "Internal Server Error", message: str = "Internal Server Error",
data: Optional[Union[Dict, List, str]] = None, data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None, error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Return a response for an internal server error. Return a response for an internal server error.
Args: Args:
message (str): A message describing the error. message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in 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. error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns: Returns:
Dict[str, Any]: A dictionary representing the JSON response. Dict[str, Any]: A dictionary representing the JSON response.
""" """
return ResponseHelper.build_response(False, message, data, 500, error) return ResponseHelper.build_response(False, message, data, 500, error)
@staticmethod @staticmethod
def unauthorized( def unauthorized(
message: str = "Unauthorized", message: str = "Unauthorized",
data: Optional[Union[Dict, List, str]] = None, data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None, error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Return a response for an unauthorized request. Return a response for an unauthorized request.
Args: Args:
message (str): A message describing the error. message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in 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. error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns: Returns:
Dict[str, Any]: A dictionary representing the JSON response. Dict[str, Any]: A dictionary representing the JSON response.
""" """
return ResponseHelper.build_response(False, message, data, 401, error) return ResponseHelper.build_response(False, message, data, 401, error)
@staticmethod @staticmethod
def forbidden( def forbidden(
message: str = "Forbidden", message: str = "Forbidden",
data: Optional[Union[Dict, List, str]] = None, data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None, error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Return a response for a forbidden request. Return a response for a forbidden request.
Args: Args:
message (str): A message describing the error. message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in 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. error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns: Returns:
Dict[str, Any]: A dictionary representing the JSON response. Dict[str, Any]: A dictionary representing the JSON response.
""" """
return ResponseHelper.build_response(False, message, data, 403, error) return ResponseHelper.build_response(False, message, data, 403, error)
@staticmethod @staticmethod
def not_found( def not_found(
message: str = "Resource not found", message: str = "Resource not found",
data: Optional[Union[Dict, List, str]] = None, data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None, error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Return a response for a not found resource. Return a response for a not found resource.
Args: Args:
message (str): A message describing the error. message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in 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. error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns: Returns:
Dict[str, Any]: A dictionary representing the JSON response. Dict[str, Any]: A dictionary representing the JSON response.
""" """
return ResponseHelper.build_response(False, message, data, 404, error) return ResponseHelper.build_response(False, message, data, 404, error)
@staticmethod @staticmethod
def unprocessable_entity( def unprocessable_entity(
message: str = "Unprocessable entity", message: str = "Unprocessable entity",
data: Optional[Union[Dict, List, str]] = None, data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None, error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Return a response for an unprocessable entity. Return a response for an unprocessable entity.
Args: Args:
message (str): A message describing the error. message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in 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. error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns: Returns:
Dict[str, Any]: A dictionary representing the JSON response. Dict[str, Any]: A dictionary representing the JSON response.
""" """
return ResponseHelper.build_response(False, message, data, 422, error) return ResponseHelper.build_response(False, message, data, 422, error)
@staticmethod @staticmethod
def method_not_allowed( def method_not_allowed(
message: str = "Method Not Allowed", message: str = "Method Not Allowed",
data: Optional[Union[Dict, List, str]] = None, data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None, error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Return a response for a method not allowed error. Return a response for a method not allowed error.
Args: Args:
message (str): A message describing the error. message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in 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. error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns: Returns:
Dict[str, Any]: A dictionary representing the JSON response. Dict[str, Any]: A dictionary representing the JSON response.
""" """
return ResponseHelper.build_response(False, message, data, 405, error) return ResponseHelper.build_response(False, message, data, 405, error)
@staticmethod @staticmethod
def bad_request( def bad_request(
message: str = "Bad Request", message: str = "Bad Request",
data: Optional[Union[Dict, List, str]] = None, data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None, error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Return a response for a bad request error. Return a response for a bad request error.
Args: Args:
message (str): A message describing the error. message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in 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. error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns: Returns:
Dict[str, Any]: A dictionary representing the JSON response. Dict[str, Any]: A dictionary representing the JSON response.
""" """
return ResponseHelper.build_response(False, message, data, 400, error) return ResponseHelper.build_response(False, message, data, 400, error)
+3 -2
View File
@@ -1,2 +1,3 @@
from .kafka import KafkaIntegration from .kafka import KafkaIntegration
from .simbrella import SimbrellaClient from .simbrella import SimbrellaClient
from .bank_service import BankService
+25
View File
@@ -0,0 +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)
raise
+156 -156
View File
@@ -1,156 +1,156 @@
from confluent_kafka import Consumer, Producer from confluent_kafka import Consumer, Producer
import json import json
from app.utils.logger import logger from app.utils.logger import logger
from app.config import settings from app.config import settings
import requests import requests
from app.integrations.simbrella import SimbrellaClient from app.integrations.simbrella import SimbrellaClient
class KafkaIntegration: class KafkaIntegration:
BASE_URL = settings.BANK_CALL_BASE_URL BASE_URL = settings.BANK_CALL_BASE_URL
_consumer = None _consumer = None
_consumer_config = { _consumer_config = {
"bootstrap.servers": settings.KAFKA_BROKER, "bootstrap.servers": settings.KAFKA_BROKER,
"group.id": "loan-service-consumer", "group.id": "loan-service-consumer",
"auto.offset.reset": "earliest", "auto.offset.reset": "earliest",
"enable.auto.commit": True, "enable.auto.commit": True,
} }
@staticmethod @staticmethod
def _get_consumer(): def _get_consumer():
"""Kafka consumer""" """Kafka consumer"""
if not KafkaIntegration._consumer: if not KafkaIntegration._consumer:
KafkaIntegration._consumer = Consumer(KafkaIntegration._consumer_config) KafkaIntegration._consumer = Consumer(KafkaIntegration._consumer_config)
logger.info( logger.info(
f"Consumer connected to Kafka broker at {KafkaIntegration._consumer_config['bootstrap.servers']}" f"Consumer connected to Kafka broker at {KafkaIntegration._consumer_config['bootstrap.servers']}"
) )
return KafkaIntegration._consumer return KafkaIntegration._consumer
@staticmethod @staticmethod
def delivery_report(err, msg): def delivery_report(err, msg):
"""Called once for each message produced""" """Called once for each message produced"""
if err is not None: if err is not None:
logger.error(f"Message delivery failed: {err}") logger.error(f"Message delivery failed: {err}")
raise RuntimeError(f"Message delivery failed: {err}") raise RuntimeError(f"Message delivery failed: {err}")
else: else:
logger.debug( logger.debug(
f"Message delivered to {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}" f"Message delivered to {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}"
) )
@staticmethod @staticmethod
def receive_messages(topics, timeout): def receive_messages(topics, timeout):
""" """
Receive messages from a Kafka topic. Receive messages from a Kafka topic.
:param topics: The Kafka topics to subscribe to :param topics: The Kafka topics to subscribe to
:param timeout: Time to wait for a message (in seconds) :param timeout: Time to wait for a message (in seconds)
:return: The message value (decoded) or None if no message is received :return: The message value (decoded) or None if no message is received
""" """
consumer = KafkaIntegration._get_consumer() consumer = KafkaIntegration._get_consumer()
consumer.subscribe(topics) consumer.subscribe(topics)
logger.info( logger.info(
f"Waiting for messages from topic {topics} with this timeout: {timeout}..." f"Waiting for messages from topic {topics} with this timeout: {timeout}..."
) )
message = [] message = []
try: try:
msg = consumer.poll(timeout=timeout) msg = consumer.poll(timeout=timeout)
logger.info(str(msg.value)) logger.info(str(msg.value))
logger.info( logger.info(
f"Received message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: " f"Received message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: "
) )
if msg is None: if msg is None:
logger.info(f"No message received from topic {topics} within timeout") logger.info(f"No message received from topic {topics} within timeout")
return None return None
if msg.error(): if msg.error():
logger.info(f"Consumer error: {msg.error()}") logger.info(f"Consumer error: {msg.error()}")
raise RuntimeError(f"Consumer error: {msg.error()}") raise RuntimeError(f"Consumer error: {msg.error()}")
# Decode and return the message value # Decode and return the message value
message_value= {"value":''} message_value= {"value":''}
if msg.value() != "": if msg.value() != "":
message_value = msg.value().decode("utf-8") message_value = msg.value().decode("utf-8")
logger.info( logger.info(
f"Received message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: {message_value}" f"Received message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: {message_value}"
) )
# Invalid Json will stop this process # Invalid Json will stop this process
try: try:
message = json.loads(message_value) if message_value else None message = json.loads(message_value) if message_value else None
except ValueError as e: except ValueError as e:
return message # Invalid JSON JUST TURN BACK HERE return message # Invalid JSON JUST TURN BACK HERE
else: else:
pass # valid json pass # valid json
# Call the endpoint if provided # Call the endpoint if provided
if message: if message:
logger.info( logger.info(
f"Received message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: {message}" f"Received message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: {message}"
) )
current_topic = msg.topic() current_topic = msg.topic()
if current_topic=="PROCESS_PAYMENT": if current_topic=="PROCESS_PAYMENT":
KafkaIntegration._call_disbursement_service(message) KafkaIntegration._call_disbursement_service(message)
if current_topic=="LOAN_REPAYMENT": if current_topic=="LOAN_REPAYMENT":
KafkaIntegration._call_collect_loan_service(message) KafkaIntegration._call_collect_loan_service(message)
logger.info( logger.info(
f"Loan Repayment message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: {message}" f"Loan Repayment message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: {message}"
) )
return message return message
except Exception as e: except Exception as e:
logger.error(f"Error while receiving message: {e}") logger.error(f"Error while receiving message: {e}")
raise raise
#return [] #return []
@staticmethod @staticmethod
def close_consumer(): def close_consumer():
"""Shutdown consumer""" """Shutdown consumer"""
consumer = KafkaIntegration._get_consumer() consumer = KafkaIntegration._get_consumer()
consumer.close() consumer.close()
logger.info("Kafka consumer closed") logger.info("Kafka consumer closed")
@staticmethod @staticmethod
def _call_disbursement_service(message): def _call_disbursement_service(message):
"""Call the disbursement service with the received message""" """Call the disbursement service with the received message"""
logger.info(f"Calling disbursement service with message: {message}") logger.info(f"Calling disbursement service with message: {message}")
try: try:
response = SimbrellaClient.disburse_loan(message) response = SimbrellaClient.disburse_loan(message)
logger.info( logger.info(
f"Successfully sent message to disbursement service: {response}" f"Successfully sent message to disbursement service: {response}"
) )
# LoanService.set_disbursement_date(loan_id=loan_data['debtId'], # LoanService.set_disbursement_date(loan_id=loan_data['debtId'],
# customer_id=customerId) # must mark it on way out # customer_id=customerId) # must mark it on way out
# #
except Exception as e: except Exception as e:
logger.info(f"Failed to call disbursement service: {e}") logger.info(f"Failed to call disbursement service: {e}")
#raise #raise
@staticmethod @staticmethod
def _call_collect_loan_service(message): def _call_collect_loan_service(message):
"""Call the collect loan service with the received message""" """Call the collect loan service with the received message"""
logger.info(f"Calling collect_loan service with message: {message}") logger.info(f"Calling collect_loan service with message: {message}")
try: try:
#Calling CollectLoan endpoint with data: {'transactionId': 'TRCVIC85641527829', 'customerId': 'ZX48440946', 'productId': 'AMPC', 'loanRef': 'TRCVIC85641527829USSDAMPC', 'debtId': '014231'} #Calling CollectLoan endpoint with data: {'transactionId': 'TRCVIC85641527829', 'customerId': 'ZX48440946', 'productId': 'AMPC', 'loanRef': 'TRCVIC85641527829USSDAMPC', 'debtId': '014231'}
response = SimbrellaClient.collect_loan_user_initiated(message) response = SimbrellaClient.collect_loan_user_initiated(message)
logger.info( logger.info(
f"Successfully sent message to collect_loan service: {response}" f"Successfully sent message to collect_loan service: {response}"
) )
except Exception as e: except Exception as e:
logger.error(f"Failed to call collect_loan service: {e}") logger.error(f"Failed to call collect_loan service: {e}")
# raise # raise
+545 -391
View File
@@ -1,391 +1,545 @@
import requests import requests
from app.config import settings from app.config import settings
from app.helpers.response_helper import ResponseHelper from app.helpers.response_helper import ResponseHelper
from app.services.loan import LoanService # from app.routes.autocall import verify_transaction
from app.utils.auth import get_headers from app.models.customer import Customer
from app.utils.extras import preprocess_loan_charges_data from app.services.loan import LoanService
import random from app.services.loan_repayment_schedule import LoanRepaymentScheduleService
import random from app.utils.auth import get_headers
import string from app.utils.extras import preprocess_loan_charges_data
from app.utils.logger import logger import random
from flask import jsonify, current_app import string
from app.services.transactions import TransactionService from app.extensions import db
from app.services.repayment import RepaymentService from app.utils.logger import logger
from app.extensions import db from flask import jsonify, current_app
from app.services.repayments_data import RepaymentsData from app.services.transactions import TransactionService
from app.services.salary import SalaryService from app.services.repayment import RepaymentService
from app.enums.loan_status import LoanStatus from app.extensions import db
from decimal import Decimal, ROUND_HALF_UP from app.services.repayments_data import RepaymentsData
from requests.exceptions import SSLError, RequestException,Timeout from app.services.salary import SalaryService
import sys from app.enums.loan_status import LoanStatus
from app.models.loan_repayment_schedule import LoanRepaymentSchedule
from decimal import Decimal, ROUND_HALF_UP
class SimbrellaClient: from requests.exceptions import SSLError, RequestException, Timeout, ReadTimeout, ConnectTimeout
import sys
BANK_CALL_BASE_URL = settings.BANK_CALL_BASE_URL import socket
BANK_CALL_SMS_BASE_URL = settings.BANK_CALL_SMS_BASE_URL from app.helpers.collect_loan_helper import CollectLoanHelper
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 class SimbrellaClient:
BANK_CALL_BASE_URL = settings.BANK_CALL_BASE_URL
@staticmethod BANK_CALL_SMS_BASE_URL = settings.BANK_CALL_SMS_BASE_URL
def disburse_loan(data): BANK_CALL_DISBURSE_LOAN_ENDPOINT = settings.BANK_CALL_DISBURSE_LOAN_ENDPOINT
api_url = f"{SimbrellaClient.BANK_CALL_BASE_URL}/{SimbrellaClient.BANK_CALL_DISBURSE_LOAN_ENDPOINT}" BANK_CALL_COLLECT_LOAN_ENDPOINT = settings.BANK_CALL_COLLECT_LOAN_ENDPOINT
logger.info(f"Calling DisburseLoan api_url==> : {api_url}") BANK_CALL_TRANSACTION_VERIFY = settings.BANK_CALL_TRANSACTION_VERIFY
logger.info(f"Calling DisburseLoan endpoint with data: {data}") BANK_HEALTH_CHECK_ENDPOINT = settings.BANK_HEALTH_CHECK_ENDPOINT
BANK_CALL_API_TIME_OUT = settings.BANK_CALL_API_TIME_OUT
# Check if the transaction exists
logger.info(f"Checking if transaction exists") @staticmethod
transaction = TransactionService.get_transaction_by_transaction_id(transaction_id=data['transactionId']) def disburse_loan(data):
logger.info(f"Loan Response From Database ** : {transaction}") api_url = f"{SimbrellaClient.BANK_CALL_BASE_URL}{SimbrellaClient.BANK_CALL_DISBURSE_LOAN_ENDPOINT}"
logger.info(f"Calling DisburseLoan api_url==> : {api_url}")
# If transaction is not found logger.info(f"Calling DisburseLoan endpoint with data: {data}")
if not transaction:
logger.info(f"Transaction id: {data['transactionId']}, was not found") # Check if the transaction exists
return 0 logger.info(f"Checking if transaction exists")
transaction = TransactionService.get_transaction_by_transaction_id(transaction_id=data['transactionId'])
# Fetch the loan based on the transaction_id logger.info(f"Loan Response From Database ** : {transaction}")
logger.info(f"Fetching the loan with transaction ID: {data['transactionId']}")
loan = LoanService.get_loan_by_transaction_id(transaction_id=data['transactionId']) # If transaction is not found
logger.info(f"Response from database: {loan}") if not transaction:
logger.info(f"Transaction id: {data['transactionId']}, was not found")
# If loan is not found return 0
if not loan:
logger.info(f"Could not find loan with transaction id: {data['transactionId']}") # Fetch the loan based on the transaction_id
return 0 logger.info(f"Fetching the loan with transaction ID: {data['transactionId']}")
loan = LoanService.get_loan_by_transaction_id(transaction_id=data['transactionId'])
loan_data = loan.to_dict() logger.info(f"Response from database: {loan}")
logger.info(f"Here is your loan data: {loan_data}")
# If loan is not found
if loan_data['disburseDate'] is not None: if not loan:
logger.info( logger.info(f"Could not find loan with transaction id: {data['transactionId']}")
f"Please call verify loan : {data['transactionId']} loan send for processing at {loan_data['disburseDate']}") return 0
return 0
loan_data = loan.to_dict()
# let us set disbursement date logger.info(f"Here is your loan data: {loan_data}")
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}") if loan_data['status'] != LoanStatus.ACTIVE:
logger.info(f"Loan with transaction id: {data['transactionId']} is not active")
loan_charges = preprocess_loan_charges_data([loan_charge.to_dict() for loan_charge in loan.loan_charges]) return 0
logger.info(f"Here are your loan_charges: {loan_charges}")
if loan_data['disburseDate'] is not None:
mgt_fee = loan_charges.get("MGTFEE")['amount'] logger.info("*************************SEEM LIKE A RETRY CALL -WE WILL VERIFY Result to Continue ")
vat_fee = loan_charges.get("VAT")['amount'] logger.info(
interest_fee = loan_charges.get("INTEREST")['amount'] f"Please call verify loan : {data['transactionId']} loan sent for processing at {loan_data['disburseDate']}")
insurance_fee = loan_charges.get("INSURANCE")['amount'] # return 0 -- we need the disburseResult = '00' to be sure all is good
debtId = str(loan_data.get('debtId', "")).strip().zfill(6) if loan_data['disburseDate'] is not None and loan_data['disburseResult'] == '00':
logger.info("*************************Duplicate call to completed loan ")
disbursement_data = { logger.info(
"transactionId": loan_data.get('transactionId'), f"Duplicate call detected for loan : {data['transactionId']} loan sent for processing at {loan_data['disburseDate']}")
"FbnTransactionId": loan_data.get('transactionId'), return 0
"debtId": debtId,
"customerId": loan_data.get('customerId'), # let us set disbursement date
"accountId": loan_data.get('accountId'), LoanService.set_disbursement_date(loan_data['debtId'],
"productId": str(loan_data.get('productId', "")), loan_data['customerId']) # toda this must return something
"provideAmount": loan_data.get('currentLoanAmount'), logger.info(f"Here is your loan data after setting disbursement date: {loan_data}")
"collectAmountInterest": interest_fee,
"collectAmountMgtFee": mgt_fee, loan_charges = preprocess_loan_charges_data([loan_charge.to_dict() for loan_charge in loan.loan_charges])
"collectAmountInsurance": insurance_fee, logger.info(f"Here are your loan_charges: {loan_charges}")
"collectAmountVAT": vat_fee,
"countryId": "01", mgt_fee = loan_charges.get("MGTFEE")['amount']
"comment": "Loan Disbursement", vat_fee = loan_charges.get("VAT")['amount']
} interest_fee = loan_charges.get("INTEREST")['amount']
insurance_fee = loan_charges.get("INSURANCE")['amount']
try: product_id = str(loan_data.get('productId', ""))
logger.info(f"Here is your Disbursement Request data ****** : {disbursement_data}") debtId = str(loan_data.get('debtId', "")).strip().zfill(6)
response = requests.post(api_url, json=disbursement_data, timeout=10, headers=get_headers()) disbursement_data = {
if response.status_code == 404: "transactionId": loan_data.get('transactionId'),
logger.error("Received 404 from external service") "fbnTransactionId": loan_data.get('transactionId'),
return ResponseHelper.error("Disbursement Service url not found (404)", status_code=404) "debtId": debtId,
logger.info(f"Disbursement response: {response.json()}") "customerId": loan_data.get('customerId'),
result = response.json() "accountId": loan_data.get('accountId'),
LoanService.set_disbursement_result(loan_data['debtId'],result.get('responseCode', ''), result.get('responseMessage', '')) "productId": str(loan_data.get('productId', "")),
return ResponseHelper.success(response.json(), "Successful") "provideAmount": loan_data.get('currentLoanAmount'),
except Exception as e: "collectAmountInterest": interest_fee if product_id != '3MPC' else 0,
logger.info(f"Failed to call Disbursement endpoint: {e}") "collectAmountMgtFee": mgt_fee,
return 0 "collectAmountInsurance": insurance_fee,
"collectAmountVAT": vat_fee,
return 1 "countryId": "01",
"comment": "Loan Disbursement",
@staticmethod }
def verify_transaction(data): # '''
api_url = f"{SimbrellaClient.BANK_CALL_BASE_URL}{SimbrellaClient.BANK_CALL_TRANSACTION_VERIFY}" # { Veryfing with the bank
sms_url = f"{SimbrellaClient.BANK_CALL_SMS_BASE_URL}/singleSMS" # "transactionId": "string",
logger.info(f"Calling TransactionVerify api_url==> : {api_url}") # "fbnTransactionId": "string",
# "debtId": "string",
# Check if the transaction exists # "customerId": "string",
logger.info(f"Checking if transaction exists") # "accountId": "string",
transaction = TransactionService.get_transaction_by_transaction_id(transaction_id=data['transactionId']) # "productId": "string",
transaction_data = transaction.to_dict() # "provideAmount": 0,
logger.info(f"Loan Response From Database ** : {transaction}") # "collectAmountInterest": 0,
# "collectAmountMgtFee": 0,
# If transaction is not found # "collectAmountInsurance": 0,
if not transaction: # "collectAmountVAT": 0,
logger.info(f"Transaction id: {data['transactionId']}, was not found") # "countryId": "string",
return 0 # "comment": "string"
# }
# 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']) try:
logger.info(f"Response from database: {loan}") 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,
# If loan is not found headers=get_headers())
if not loan: logger.info(
logger.info(f"Could not find loan with transaction id: {data['transactionId']}") f"Call to bank end point returned with Here is your Disbursement Request data ****** : {disbursement_data}")
return 0 if response.status_code == 404:
logger.error("")
loan_data = loan.to_dict() LoanService.set_disbursement_loan_description(loan_data['debtId'],
logger.info(f"Here is your loan data: {loan_data}") "Disbursement Service url not found (404)")
return ResponseHelper.error("Disbursement Service url not found (404)", status_code=404)
if loan_data['disburseDate'] is not None and loan_data['disburseVerify'] is None :
LoanService.set_disburse_verify_date(loan_data['debtId'],loan_data['customerId']) logger.info(f"Disbursement response: {response.json()}")
loan_data = loan.to_dict()
logger.info(f"Here is your loan data after setting verify date: {loan_data}") if response.status_code == 200:
logger.info(f"Good to Verify transaction id: {data['transactionId']}") result = response.json()
else: LoanService.set_disbursement_result(loan_data['debtId'], result.get('responseCode', ''),
logger.info( result.get('responseMessage', ''))
f"Please call disburse loan : {data['transactionId']} loan send for processing first") reload_loan = LoanService.get_loan_by_transaction_id(transaction_id=data['transactionId'])
return 0 reload_loan_data = reload_loan.to_dict()
#mark repayment schedule as active
repayment_schedule = LoanRepaymentScheduleService.get_repayment_schedule_by_loan_id(
verify_data = { reload_loan_data['debtId'], include_paid=False)
"customerId": loan_data.get('customerId'), logger.info(f'Loan repayment schedule: {repayment_schedule}')
"accountId": loan_data.get('accountId'), if repayment_schedule:
"transactionId": loan_data.get('transactionId'), for schedule in repayment_schedule:
"transactionType": "provide", logger.info(f"Updating repayment schedule ID {schedule.id} status to ACTIVE")
"fbnTransactionId": loan_data.get('transactionId'), LoanRepaymentScheduleService.update_repayment_schedule_status_to_active(schedule.id)
"countryId": "NG",
"requestId": loan_data.get('transactionId') SimbrellaClient.verify_disbursement_transaction(reload_loan_data)
}
return ResponseHelper.success(response.json(), "Successful")
try:
logger.info(f"Here is your TransactionVerify Request data ****** : {verify_data}") else:
response = requests.post(api_url, json=verify_data, timeout=10, headers=get_headers()) logger.error("")
if response.status_code == 404: errorMessage = "Unable to complete Disbursement Service with HTTP status code: " + str(
logger.error("Received 404 from external service") response.status_code)
return ResponseHelper.error("Verify Service url not found (404)", status_code=404) LoanService.set_disbursement_loan_description(loan_data['debtId'], errorMessage)
result = response.json() return ResponseHelper.error(errorMessage, status_code=response.status_code)
logger.info(f"this is verify result, {result}")
LoanService.set_disburse_verify_result(loan_data['debtId'],result.get('responseCode', ''), result.get('responseMessage', '')) except requests.exceptions.HTTPError as errh:
sms_data = { print(f"Disbursement HTTP Error: {errh}")
"dest": transaction_data.get('phone_number') or settings.TEST_NO, except requests.exceptions.ConnectionError as errc:
"text": f"Transaction {loan_data.get('transactionId')} verified successfully", print(f"Disbursement Error Connecting: {errc}")
"unicode": True except requests.exceptions.Timeout as errt:
} print(f"Disbursement Timeout Error: {errt}")
try: except requests.exceptions.RequestException as err:
sms_response = requests.post(sms_url, json=sms_data, timeout=10, headers=get_headers()) print(f"Disbursement - Unexpected Error Occurred: {err}")
sms_response.raise_for_status() # Raise an exception for 4xx or 5xx status codes except Exception as e:
logger.info(f"Failed to call Disbursement endpoint: {e}")
result = sms_response.json() return 0
logger.info(f"SMS Response JSON: {result}")
if result.get('isSuccess'): # try:
logger.info(f"sms sent successfully") # logger.info(f"Calling Bank Disbursement with Request data ****** : {disbursement_data}")
return ResponseHelper.success(response.json(), "Successful") # response = requests.post(api_url, json=disbursement_data, timeout=SimbrellaClient.BANK_CALL_API_TIME_OUT,
logger.info(f"sms failed!") # headers=get_headers())
return 1 # logger.info(
except requests.RequestException as e: # f"Call to bank end point returned with Here is your Disbursement Request data ****** : {disbursement_data}")
# Handle the exception # if response.status_code == 404:
logger.error(f"Failed to send SMS: {e}") # logger.error("")
return 0 # LoanService.set_disbursement_loan_description(loan_data['debtId'],
except Exception as e: # "Disbursement Service url not found (404)")
logger.info(f"Failed to call TransactionVerify endpoint: {e}") # return ResponseHelper.error("Disbursement Service url not found (404)", status_code=404)
return 0 #
# logger.info(f"Disbursement response: {response.json()}")
@staticmethod #
def collect_loan_user_initiated(data): # if response.status_code == 200:
# InitiatedBy = USER_INITIATED # result = response.json()
try: # LoanService.set_disbursement_result(loan_data['debtId'], result.get('responseCode', ''),
logger.info(f"Calling CollectLoan collect_loan_user_initiated ******* endpoint with data: {data}") # result.get('responseMessage', ''))
return SimbrellaClient._collect_loan(data, "1") # reload_loan = LoanService.get_loan_by_transaction_id(transaction_id=data['transactionId'])
except Exception as e: # reload_loan_data = reload_loan.to_dict()
logger.error(f"Error in collect_loan_user_initiated: {e}") # SimbrellaClient.verify_disbursement_transaction(reload_loan_data)
# return ResponseHelper.error( # return ResponseHelper.success(response.json(), "Successful")
# message="Failed to collect loan for user initiated ", #
# status_code=500, # else:
# error=str(e) # logger.error("")
# ) # errorMessage = "Unable to complete Disbursement Service with HTTP status code: " + str(
# response.status_code)
@staticmethod # LoanService.set_disbursement_loan_description(loan_data['debtId'], errorMessage)
def collect_loan_user_salary_detect(data): # return ResponseHelper.error(errorMessage, status_code=response.status_code)
try:
return SimbrellaClient._collect_loan(data, "2") # except Exception as e:
except Exception as e: # logger.info(f"Failed to call Disbursement endpoint: {e}")
logger.error(f"Error in collect_loan_user_salary_detect: {e}") # return 0
return ResponseHelper.error(
message="Failed to collect loan for salary detection", return 1
status_code=500,
error=str(e) @staticmethod
) def verify_disbursement_transaction(loan_data):
if loan_data['disburseResult'] and loan_data['disburseResult'] == '00':
SimbrellaClient.verify_transaction(loan_data)
@staticmethod
def collect_loan_user_due_payment(data): @staticmethod
# InitiatedBy = REPAYMENT_DUE def verify_transaction(data):
return SimbrellaClient._collect_loan(data,"3") 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}")
@staticmethod
def _collect_loan(data, collectionMethod: str): # Check if the transaction exists
api_url = f"{SimbrellaClient.BANK_CALL_BASE_URL}{SimbrellaClient.BANK_CALL_COLLECT_LOAN_ENDPOINT}" logger.info(f"Checking if transaction exists")
logger.info(f"Calling CollectLoan api_url==> : {api_url}") transaction = TransactionService.get_transaction_by_transaction_id(transaction_id=data['transactionId'])
logger.info(f"Calling CollectLoan endpoint with data: {data}") transaction_data = transaction.to_dict()
logger.info(f"Loan Response From Database ** : {transaction}")
repayment = RepaymentService.get_repayment_by_id(id=data['Id'])
if not repayment: # If transaction is not found
logger.info(f"Repayment with id: {data['Id']} not found") if not transaction:
return ResponseHelper.error("Repayment not found") logger.info(f"Transaction id: {data['transactionId']}, was not found")
return 0
repayment_data = repayment.to_dict()
loan = LoanService.get_loan_by_loan_id(loan_id=int(repayment_data['loanId'])) # Fetch the loan based on the transaction_id
if not loan: logger.info(f"Fetching the loan with transaction ID: {data['transactionId']}")
logger.info(f"Loan with debtId: {repayment_data['loanId']} not found") loan = LoanService.get_loan_by_transaction_id(transaction_id=data['transactionId'])
return ResponseHelper.error("Loan not found") logger.info(f"Response from database: {loan}")
loan_data = loan.to_dict() # If loan is not found
logger.info(f"Loan data: {loan_data}") if not loan:
logger.info(f"Could not find loan with transaction id: {data['transactionId']}")
if repayment_data['repayDate'] is not None: return 0
logger.info(f"Repayment already processed at {repayment_data['repayDate']}")
return ResponseHelper.error("Repayment already processed") loan_data = loan.to_dict()
logger.info(f"Here is your loan data: {loan_data}")
RepaymentService.set_repay_date(repayment_data['Id'], repayment_data['customerId'])
repayment = RepaymentService.get_repayment_by_transaction_id(transaction_id=data['transactionId']) if loan_data['disburseDate'] is not None and loan_data['disburseVerify'] is None:
repayment_data = repayment.to_dict() LoanService.set_disburse_verify_date(loan_data['debtId'], loan_data['customerId'])
loan_data = loan.to_dict()
debtId = str(loan_data.get('debtId', "")).strip().zfill(6) logger.info(f"Here is your loan data after setting verify date: {loan_data}")
t_id = ''.join(random.choices(string.ascii_uppercase, k=22)) logger.info(f"Good to Verify transaction id: {data['transactionId']}")
collect_loan_data = { else:
"transactionId": t_id, logger.info(
"fbnTransactionId": loan_data['transactionId'], f"Please call disburse loan : {data['transactionId']} loan send for processing first")
"debtId": debtId, return 0
"customerId": repayment_data['customerId'],
"accountId": loan_data['accountId'], verify_data = {
"productId": repayment_data['productId'], "customerId": loan_data.get('customerId'),
"collectAmount": loan_data['balance'] or 0, "accountId": loan_data.get('accountId'),
"penalCharge": 0, "transactionId": loan_data.get('transactionId'),
"channel": "USSD", "transactionType": "provide",
"collectionMethod": collectionMethod, "fbnTransactionId": loan_data.get('transactionId'),
"lienAmount": 0, "countryId": "NG",
"countryId": "NG", "requestId": loan_data.get('transactionId')
"comment": "COLLECT LOAN" }
} # '''
# { Verify with bank
try: # "accountId": "string",
logger.info(f"Sending CollectLoan request............ {collect_loan_data}") # "customerId": "string",
response = requests.post(api_url, json=collect_loan_data, timeout=30, headers=get_headers()) # "transactionId": "string",
# "fbnTransactionId": "string",
logger.info(f"HTTP response object: {response}") # "transactionType": "string",
# "countryId": "string",
if response.status_code == 404: # "requestId": "string"
RepaymentService.set_repay_result( # }
repayment_data['Id'], # '''
'404', try:
'Collection Service url not found' 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,
logger.error("Received 404 from external service") headers=get_headers())
return ResponseHelper.error("Collection Service URL not found", status_code=404) if response.status_code == 404:
logger.error("Received 404 from external service")
result = response.json() return ResponseHelper.error("Verify Service url not found (404)", status_code=404)
logger.info(f"CollectLoan response: {result}") result = response.json()
#check for res 00 and status 200
RepaymentService.set_repay_result( logger.info(f"this is verify result, {result}")
repayment_data['Id'], LoanService.set_disburse_verify_result(loan_data['debtId'], result.get('responseCode', ''),
result.get('responseCode', ''), result.get('responseMessage', ''))
result.get('responseMessage', '')
) customer = Customer.get_customer(loan_data.get('customerId'))
if customer:
data_to_add = { misisdn = customer.msisdn
"transactionId": result.get('transactionId') or collect_loan_data.get('transactionId'), else:
"fbnTransactionId": loan_data['transactionId'], logger.info(f"Customer does not exist for customer id: {loan_data.get('customerId')}")
"accountId": result.get('accountId') or collect_loan_data.get('accountId'), misisdn = settings.TEST_NO
"customerId": result.get('customerId') or collect_loan_data.get('customerId'),
"amountCollected": float(result.get('amountCollected', 0)), sms_data = {
"repaymentAmount": collect_loan_data.get('collectAmount'), "dest": misisdn,
"responseCode": result.get('responseCode'), "text": f"Transaction {loan_data.get('transactionId')} verified successfully",
"responseDescr": result.get('responseMessage'), "unicode": True
"balance": round(float(result.get('lienAmount', 0)), 2) }
}
try:
new_repayment_data = RepaymentsData.add_repayment_data(data_to_add) TransactionService.create_transaction(loan_data['transactionId'], loan_data['accountId'],
if new_repayment_data: loan_data['customerId'], "send_sms", "USSD")
logger.info(f"Repayment data added: {new_repayment_data.to_dict()}") except Exception as e:
else: logger.info(f"Failed to LOG SMS Transaction Record: {e}")
logger.warning("Failed to add repayment data")
try:
if result.get('responseCode') == '00':
amount_collected = Decimal(str(result.get('amountCollected', 0))).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) sms_response = requests.post(sms_url, json=sms_data, timeout=SimbrellaClient.BANK_CALL_API_TIME_OUT,
logger.info(f"Amount collected: {amount_collected}") headers=get_headers())
sms_response.raise_for_status() # Raise an exception for 4xx or 5xx status codes
if loan.balance is None or loan.balance <= 0:
logger.warning(f"Loan ID {loan.id} has no balance. Skipping loan update.") result = sms_response.json()
return ResponseHelper.error("Loan has no balance. Skipping.") logger.info(f"SMS Response JSON: {result}")
if result.get('isSuccess'):
try: logger.info(f"sms sent successfully")
logger.info(f"Updating loan balance for loan ID {loan_data['debtId']} with amount collected: {amount_collected}") return ResponseHelper.success(response.json(), "Successful")
updated_loan = LoanService.update_loan_balance(int(loan_data['debtId']), amount_collected) logger.info(f"sms failed!")
logger.info(f"Updated loan: {updated_loan}") return 1
except Exception as ex: except requests.RequestException as e:
logger.error(f"Error updating loan balance for loan ID {loan.id}: {ex}") # Handle the exception
return ResponseHelper.error("Error updating loan balance") logger.error(f"Failed to send SMS: {e}")
return 0
try: except Exception as e:
updated_balance = Decimal(str(updated_loan['balance'])).quantize(Decimal('0.01')) logger.info(f"Failed to call TransactionVerify endpoint: {e}")
logger.info(f"Updated balance: {updated_balance}") return 0
if updated_balance <= Decimal('0.00'): @staticmethod
logger.info('Loan fully repaid') def collect_loan_user_initiated(data):
repaid = LoanService.update_status(updated_loan['debtId'], LoanStatus.REPAID) # InitiatedBy = USER_INITIATED
logger.info(f'Updated loan with repaid status: {repaid}') try:
else: logger.info(f"Calling CollectLoan collect_loan_user_initiated ******* endpoint with data: {data}")
logger.info('Loan partially repaid') return SimbrellaClient._collect_loan(data, "1")
partial = LoanService.update_status(updated_loan['debtId'], LoanStatus.ACTIVE_PARTIAL) except Exception as e:
logger.info(f'Updated loan with partial status: {partial}') logger.error(f"Error in collect_loan_user_initiated: {e}")
except Exception as e: # return ResponseHelper.error(
logger.error(f"Error while updating loan status for debtId {updated_loan['debtId']}: {e}") # message="Failed to collect loan for user initiated ",
# status_code=500,
return ResponseHelper.success(result, "Successful") # error=str(e)
# )
except SSLError as ssl_err:
logger.exception(f"SSL error while calling Simbrella endpoint: {ssl_err}") @staticmethod
return ResponseHelper.error("SSL handshake failed with Simbrella", status_code=502, error=str(ssl_err)) def collect_loan_user_salary_detect(data):
try:
except Timeout as timeout_err: return SimbrellaClient._collect_loan(data, "2")
logger.exception(f"Timeout while calling Simbrella: {timeout_err}") except Exception as e:
return ResponseHelper.error("Connection to Simbrella timed out", status_code=504, error=str(timeout_err)) logger.error(f"Error in collect_loan_user_salary_detect: {e}")
return ResponseHelper.error(
except RequestException as req_err: message="Failed to collect loan for salary detection",
logger.exception(f"RequestException while calling Simbrella: {req_err}") status_code=500,
return ResponseHelper.error("Connection to Simbrella failed", status_code=503, error=str(req_err)) error=str(e)
)
except SystemExit as sys_exit:
logger.error(f"SystemExit was triggered: {sys_exit}") @staticmethod
return ResponseHelper.error("Unexpected shutdown detected", status_code=500, error=str(sys_exit)) def collect_loan_user_due_payment(data):
# InitiatedBy = REPAYMENT_DUE
except Exception as e: try:
logger.exception(f"Unexpected error occurred while calling CollectLoan: {e}") return SimbrellaClient._collect_loan(data, "3")
return ResponseHelper.error("Unexpected error while processing loan collection", status_code=500, error=str(e)) except Exception as e:
logger.error(f"Error in collect_loan_user_due_payment: {e}")
return ResponseHelper.error(
@staticmethod message="Failed to collect loan for due payment",
def penal_charge(data): status_code=500,
error=str(e)
api_url = f"{SimbrellaClient.BANK_CALL_BASE_URL}/PenalCharge" )
logger.info(f"Calling Penal Charge endpoint with data: {data}")
@staticmethod
try: def _collect_loan(data, collectionMethod: str):
logger.info(f"Here is your Penal Charge Request data ***** : {data}") api_url = f"{SimbrellaClient.BANK_CALL_BASE_URL}{SimbrellaClient.BANK_CALL_COLLECT_LOAN_ENDPOINT}"
logger.info(f"Calling CollectLoan api_url==> : {api_url}")
try: logger.info(f"Calling CollectLoan endpoint with data: {data}")
logger.info(f"Here is your Penal Charge Request data ****** : {data}")
response = requests.post(api_url, json=data, timeout=10, headers=get_headers()) repayment_data, loan, error = CollectLoanHelper._validate_repayment_and_loan(data)
logger.info(f"Penal Charge response: {response.json()}") if error:
return ResponseHelper.success(response.json(), "Successful") return error
loan_data = loan.to_dict()
except Exception as e:
logger.info(f"Failed to call Penal Charge endpoint: {e}") if repayment_data['repayDate'] is not None:
return ResponseHelper.error("An error occurred", 500) logger.info(f"Repayment already processed at {repayment_data['repayDate']}")
return ResponseHelper.error("Repayment already processed")
except Exception as e:
logger.info(f"Failed to call Penal Charge endpoint: {e}") RepaymentService.set_repay_date(repayment_data['Id'], repayment_data['customerId'])
raise 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
+9 -9
View File
@@ -1,10 +1,10 @@
from .transactions import Transaction from .transactions import Transaction
from .repayment import Repayment from .repayment import Repayment
from .loan import Loan from .loan import Loan
from .loan_charge import LoanCharge from .loan_charge import LoanCharge
from .customer import Customer from .customer import Customer
from .account import Account from .account import Account
from .repayments_data import RepaymentsData from .repayments_data import RepaymentsData
from .salary import Salary from .salary import Salary
__all__ = ['Transaction', 'Repayment', 'Loan', 'LoanCharge', 'Customer', 'Account', 'RepaymentsData','Salary'] __all__ = ['Transaction', 'Repayment', 'Loan', 'LoanCharge', 'Customer', 'Account', 'RepaymentsData','Salary']
+25 -25
View File
@@ -1,25 +1,25 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.extensions import db from app.extensions import db
class Account(db.Model): class Account(db.Model):
__tablename__ = 'accounts' __tablename__ = 'accounts'
id = db.Column(db.String(50), primary_key=True) id = db.Column(db.String(50), primary_key=True)
customer_id = db.Column(db.String(50), nullable=False) customer_id = db.Column(db.String(50), nullable=False)
account_type = db.Column(db.String(50)) account_type = db.Column(db.String(50))
status = db.Column(db.String(20), default='active') status = db.Column(db.String(20), default='active')
lien_amount = db.Column(db.Float, default=0.0) lien_amount = db.Column(db.Float, default=0.0)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc)) 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)) updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
customer = relationship( customer = relationship(
"Customer", "Customer",
primaryjoin="Customer.id == Account.customer_id", primaryjoin="Customer.id == Account.customer_id",
foreign_keys=[customer_id], foreign_keys=[customer_id],
back_populates="accounts", back_populates="accounts",
) )
def __repr__(self): def __repr__(self):
return f'<Account {self.id}>' return f'<Account {self.id}>'
+41 -41
View File
@@ -1,41 +1,41 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.extensions import db from app.extensions import db
class Customer(db.Model): class Customer(db.Model):
__tablename__ = 'customers' __tablename__ = 'customers'
id = db.Column(db.String(50), primary_key=True) id = db.Column(db.String(50), primary_key=True)
msisdn = db.Column(db.String(20), unique=True, nullable=False) msisdn = db.Column(db.String(20), unique=True, nullable=False)
country_code = db.Column(db.String(3), nullable=False) country_code = db.Column(db.String(3), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc)) 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)) updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
accounts = relationship( accounts = relationship(
"Account", "Account",
primaryjoin="Customer.id == Account.customer_id", primaryjoin="Customer.id == Account.customer_id",
foreign_keys="Account.customer_id", foreign_keys="Account.customer_id",
back_populates="customer", back_populates="customer",
) )
loans = relationship( loans = relationship(
"Loan", "Loan",
primaryjoin="Customer.id == Loan.customer_id", primaryjoin="Customer.id == Loan.customer_id",
foreign_keys="Loan.customer_id", foreign_keys="Loan.customer_id",
back_populates="customer", back_populates="customer",
) )
@classmethod @classmethod
def get_customer(cls, customer_id): def get_customer(cls, customer_id):
""" """
Get customer by ID. Get customer by ID.
""" """
customer = cls.query.filter_by(id=customer_id).first() customer = cls.query.filter_by(id=customer_id).first()
if not customer: if not customer:
raise ValueError(f"Customer does not exist") raise ValueError(f"Customer does not exist")
return customer return customer
def __repr__(self): def __repr__(self):
return f'<Customer {self.id}>' return f'<Customer {self.id}>'
+415 -340
View File
@@ -1,340 +1,415 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from app.extensions import db from sqlalchemy.orm import relationship
from sqlalchemy.orm import relationship from dateutil.relativedelta import relativedelta
from dateutil.relativedelta import relativedelta from datetime import timedelta
from datetime import timedelta import logging
import logging from sqlalchemy import and_, or_, not_
from sqlalchemy import and_, or_, not_ from sqlalchemy.sql import func
from sqlalchemy.sql import func from app.utils.logger import logger
from app.utils.logger import logger from app.extensions import db
from app.extensions import db from decimal import Decimal, ROUND_HALF_UP
from decimal import Decimal, ROUND_HALF_UP from datetime import datetime, timezone
from datetime import datetime, timezone
class Loan(db.Model):
class Loan(db.Model): __tablename__ = "loans"
__tablename__ = "loans"
id = db.Column(
id = db.Column( db.Integer,
db.Integer, primary_key=True,
primary_key=True, autoincrement=True,
autoincrement=True, )
) customer_id = db.Column(db.String(50), nullable=False)
customer_id = db.Column(db.String(50), nullable=False) transaction_id = db.Column(db.String(50), nullable=True)
transaction_id = db.Column(db.String(50), nullable=True) original_transaction = 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)
account_id = db.Column(db.String(50), nullable=False) offer_id = db.Column(db.String(20), nullable=False)
offer_id = db.Column(db.String(20), nullable=False) product_id = db.Column(db.String(20), nullable=True)
product_id = db.Column(db.String(20), nullable=True) collection_type = 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)
current_loan_amount = db.Column(db.Float, nullable=True) initial_loan_amount = db.Column(db.Float, nullable=False)
initial_loan_amount = db.Column(db.Float, nullable=False) default_penalty_fee = db.Column(db.Float, default=0)
default_penalty_fee = db.Column(db.Float, default=0) continuous_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)
upfront_fee = db.Column(db.Float, nullable=True, default=0.0) repayment_amount = 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)
balance = db.Column(db.Float, nullable=True, default=0.0) installment_amount = db.Column(db.Float, nullable=True, default=0.0)
installment_amount = db.Column(db.Float, nullable=True, default=0.0) status = db.Column(db.String(20), default='pending')
status = db.Column(db.String(20), default='pending') tenor = db.Column(db.Integer, nullable=True)
tenor = db.Column(db.Integer, nullable=True) due_date = db.Column(db.DateTime, nullable=True)
due_date = db.Column(db.DateTime, nullable=True) created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
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())
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)
eligible_amount = db.Column(db.Float, nullable=True, default=0.0) disburse_date = db.Column(db.DateTime, nullable=True)
disburse_date = db.Column(db.DateTime, nullable=True) disburse_verify = db.Column(db.DateTime, nullable=True)
disburse_verify = db.Column(db.DateTime, nullable=True) disburse_result = db.Column(db.String(10), nullable=True)
disburse_result = db.Column(db.String(10), nullable=True) disburse_description = db.Column(db.String(100), nullable=True)
disburse_description = db.Column(db.String(100), nullable=True) verify_result = db.Column(db.String(10), nullable=True)
verify_result = db.Column(db.String(10), nullable=True) verify_description = db.Column(db.String(100), nullable=True)
verify_description = db.Column(db.String(100), nullable=True) reference = db.Column(db.String(50), nullable=True)
reference = db.Column(db.String(50), nullable=True)
total_penal_charge = db.Column(db.Float, default=0.0)
customer = relationship( last_penal_date = db.Column(db.DateTime, nullable=True)
"Customer",
primaryjoin="Customer.id == Loan.customer_id", customer = relationship(
foreign_keys=[customer_id], "Customer",
back_populates="loans", primaryjoin="Customer.id == Loan.customer_id",
) foreign_keys=[customer_id],
back_populates="loans",
loan_charges = relationship( )
"LoanCharge",
primaryjoin="Loan.id == LoanCharge.loan_id", loan_charges = relationship(
foreign_keys="LoanCharge.loan_id", "LoanCharge",
back_populates="loan", primaryjoin="Loan.id == LoanCharge.loan_id",
) foreign_keys="LoanCharge.loan_id",
back_populates="loan",
def __repr__(self): )
return f"<Loan {self.id}>"
def __repr__(self):
def to_dict(self): return f"<Loan {self.id}>"
"""
Convert the Loan object to a dictionary format for JSON serialization. def to_dict(self):
""" """
return { Convert the Loan object to a dictionary format for JSON serialization.
'debtId': self.id, """
"customerId": self.customer_id, return {
'initialLoanAmount': self.initial_loan_amount, 'debtId': self.id,
'currentLoanAmount': self.current_loan_amount, "customerId": self.customer_id,
'defaultPenaltyFee': self.default_penalty_fee, 'initialLoanAmount': self.initial_loan_amount,
'continuousFee': self.continuous_fee, 'currentLoanAmount': self.current_loan_amount,
'collectionType': self.collection_type, 'defaultPenaltyFee': self.default_penalty_fee,
'repaymentAmount':self.repayment_amount, 'continuousFee': self.continuous_fee,
'status': self.status, 'collectionType': self.collection_type,
'productId': self.product_id, 'repaymentAmount':self.repayment_amount,
'disburseResult': self.disburse_result, 'status': self.status,
'disburseDescription': self.disburse_description, 'productId': self.product_id,
'verifyResult': self.verify_result, 'disburseResult': self.disburse_result,
'verifyDescription': self.verify_description, 'disburseDescription': self.disburse_description,
'transactionId': self.transaction_id, 'verifyResult': self.verify_result,
'accountId':self.account_id, 'verifyDescription': self.verify_description,
'dueDate': self.due_date.isoformat() if self.due_date else None, 'transactionId': self.transaction_id,
'loanDate': self.created_at.isoformat() if self.created_at else None, 'accountId':self.account_id,
'disburseDate': self.disburse_date.isoformat() if self.disburse_date else None, 'dueDate': self.due_date.isoformat() if self.due_date else None,
'disburseVerify': self.disburse_verify.isoformat() if self.disburse_verify else None, 'loanDate': self.created_at.isoformat() if self.created_at else None,
'reference': self.reference, 'disburseDate': self.disburse_date.isoformat() if self.disburse_date else None,
'balance': self.balance, 'disburseVerify': self.disburse_verify.isoformat() if self.disburse_verify else None,
'tenor': self.tenor, 'reference': self.reference,
'balance': self.balance,
} 'tenor': self.tenor,
'totalPenalCharge': self.total_penal_charge,
@classmethod 'lastPenalDate': self.last_penal_date
def get_loan_by_transaction_id(cls, transaction_id): }
return cls.query.filter_by(transaction_id=transaction_id).first()
@classmethod @classmethod
def get_loan_by_loan_id(cls, loan_id): def get_loan_by_transaction_id(cls, transaction_id):
return cls.query.filter_by(id=loan_id).first() return cls.query.filter_by(transaction_id=transaction_id).first()
@classmethod @classmethod
def set_disbursement_date(cls, loan_id, customer_id): def get_loan_by_loan_id(cls, loan_id):
""" return cls.query.filter_by(id=loan_id).first()
Update the disburse date of the loan with the given loan_id.
""" @classmethod
# Retrieve loan def set_disbursement_date(cls, loan_id, customer_id):
loan = cls.query.get(loan_id) """
Update the disburse date of the loan with the given loan_id.
if not loan: """
raise ValueError(f"Loan with ID {loan_id} does not exist.") # Retrieve loan
loan = cls.query.get(loan_id)
# Check if customer_id matches
if loan.customer_id != customer_id: if not loan:
raise ValueError(f"Customer ID {customer_id} does not match the loan's customer ID.") raise ValueError(f"Loan with ID {loan_id} does not exist.")
current_time = datetime.now() # Check if customer_id matches
logger.info(f"What is now ======= ==== ==> : {current_time}") if loan.customer_id != customer_id:
# Update loan disburse_date raise ValueError(f"Customer ID {customer_id} does not match the loan's customer ID.")
loan.disburse_date = current_time
current_time = datetime.now()
# Commit changes to database logger.info(f"What is now ======= ==== ==> : {current_time}")
try: # Update loan disburse_date
logger.info(f"Updating disburse date for loan ID {loan_id} to {current_time}") loan.disburse_date = current_time
db.session.commit()
except Exception as e: # Commit changes to database
db.session.rollback() try:
logger.error(f"Failed to update disburse date: {e}") logger.info(f"Updating disburse date for loan ID {loan_id} to {current_time}")
raise db.session.commit()
@classmethod except Exception as e:
def set_disburse_verify_date(cls, loan_id, customer_id): db.session.rollback()
""" logger.error(f"Failed to update disburse date: {e}")
Update the disburse verify date of the loan with the given loan_id. raise
""" @classmethod
# Retrieve loan def set_disburse_verify_date(cls, loan_id, customer_id):
loan = cls.query.get(loan_id) """
Update the disburse verify date of the loan with the given loan_id.
if not loan: """
raise ValueError(f"Loan with ID {loan_id} does not exist.") # Retrieve loan
loan = cls.query.get(loan_id)
# Check if customer_id matches
if loan.customer_id != customer_id: if not loan:
raise ValueError(f"Customer ID {customer_id} does not match the loan's customer ID.") raise ValueError(f"Loan with ID {loan_id} does not exist.")
current_time = datetime.now() # Check if customer_id matches
logger.info(f"What is now ======= ==== ==> : {current_time}") if loan.customer_id != customer_id:
# Update loan verify_date raise ValueError(f"Customer ID {customer_id} does not match the loan's customer ID.")
loan.disburse_verify = current_time
current_time = datetime.now()
# Commit changes to database logger.info(f"What is now ======= ==== ==> : {current_time}")
try: # Update loan verify_date
logger.info(f"Updating disburse verify date for loan ID {loan_id} to {current_time}") loan.disburse_verify = current_time
db.session.commit()
except Exception as e: # Commit changes to database
db.session.rollback() try:
logger.error(f"Failed to update disburse verify date: {e}") logger.info(f"Updating disburse verify date for loan ID {loan_id} to {current_time}")
raise db.session.commit()
except Exception as e:
db.session.rollback()
@classmethod logger.error(f"Failed to update disburse verify date: {e}")
def set_disbursement_result(cls, loan_id, result, description): raise
"""
Update the disburse result and description of the loan with the given loan_id. @classmethod
""" def set_disbursement_message(cls, loan_id, description):
# Retrieve loan """
loan = cls.query.get(loan_id) Update the disburse result and description of the loan with the given loan_id.
"""
if not loan: # Retrieve loan
raise ValueError(f"Loan with ID {loan_id} does not exist.") loan = cls.query.get(loan_id)
# Update disburse result and description if not loan:
loan.disburse_result = result raise ValueError(f"Loan with ID {loan_id} does not exist.")
loan.disburse_description = description
# Update disburse description only
# Commit changes to database loan.disburse_description = description
try:
logger.info(f"Updating disburse result for loan ID {loan_id} to {result} with description {description}") # Commit changes to database
db.session.commit() try:
except Exception as e: logger.info(f"Updating disburse result for loan ID {loan_id} with description {description}")
db.session.rollback() db.session.commit()
logger.error(f"Failed to update disbursement result: {e}") except Exception as e:
raise db.session.rollback()
@classmethod logger.error(f"Failed to update disbursement result: {e}")
def set_disburse_verify_result(cls, loan_id, result, description): raise
"""
Update the verify result and description of the loan with the given loan_id.
""" @classmethod
# Retrieve loan def set_disbursement_result(cls, loan_id, result, description):
loan = cls.query.get(loan_id) """
Update the disburse result and description of the loan with the given loan_id.
if not loan: """
raise ValueError(f"Loan with ID {loan_id} does not exist.") # Retrieve loan
loan = cls.query.get(loan_id)
# Update disburse result and description
loan.verify_result = result if not loan:
loan.verify_description = description raise ValueError(f"Loan with ID {loan_id} does not exist.")
# Commit changes to database # Update disburse result and description
try: loan.disburse_result = result
logger.info(f"Updating verify result for loan ID {loan_id} to {result} with description {description}") loan.disburse_description = description
db.session.commit()
except Exception as e: # Commit changes to database
db.session.rollback() try:
logger.error(f"Failed to update verify result: {e}") logger.info(f"Updating disburse result for loan ID {loan_id} to {result} with description {description}")
raise db.session.commit()
@classmethod except Exception as e:
def get_latest_loan_without_disburse_date(cls): db.session.rollback()
""" logger.error(f"Failed to update disbursement result: {e}")
Get the latest loan without a disbursement date. raise
"""
return cls.query.filter(
cls.disburse_date.is_(None) @classmethod
).order_by(cls.created_at.desc()).first() def set_disburse_verify_result(cls, loan_id, result, description):
"""
@classmethod Update the verify result and description of the loan with the given loan_id.
def get_latest_loan_with_disburse_date(cls): """
""" # Retrieve loan
Get the latest loan with a disbursement date and no verification date. loan = cls.query.get(loan_id)
"""
return cls.query.filter( if not loan:
cls.disburse_date.isnot(None), raise ValueError(f"Loan with ID {loan_id} does not exist.")
cls.disburse_verify.is_(None)
).order_by(cls.created_at.desc()).first() # Update disburse result and description
loan.verify_result = result
@classmethod loan.verify_description = description
def get_customer_loans(cls, customer_id):
""" # Commit changes to database
Get customer's active loans and sum by customer_id. try:
""" logger.info(f"Updating verify result for loan ID {loan_id} to {result} with description {description}")
customer_loans = cls.query.filter_by( customer_id = customer_id).all() db.session.commit()
if not customer_loans: except Exception as e:
raise ValueError(f"Customer with Id {customer_id} does not have any loan.") db.session.rollback()
logger.error(f"Failed to update verify result: {e}")
total_amount = ( raise
cls.query.with_entities(func.coalesce(func.sum(cls.balance), 0.0)) @classmethod
.filter_by(customer_id=customer_id) def get_latest_loan_without_disburse_date(cls):
.scalar() """
) Get the latest loan without a disbursement date.
"""
logger.info(f"Found {len(customer_loans)} loans for customer ID: {customer_id} with total amount: {total_amount}") logger.info("Fetching latest loan without disburse date")
return customer_loans, total_amount try:
@classmethod return cls.query.filter(
def get_customer_active_loans(cls, customer_id): cls.disburse_date.is_(None)
""" ).order_by(cls.created_at.desc()).first()
Get customer's active loans and sum by customer_id. except Exception as e:
""" logger.error(f"Error fetching latest loan without disburse date: {e}")
customer_loans = cls.query.filter( raise
cls.customer_id == customer_id,
cls.status != 'repaid' @classmethod
).all() def get_latest_loan_with_disburse_date(cls):
"""
if not customer_loans: Get the latest loan with a disbursement date and no verification date.
raise ValueError(f"Customer with Id {customer_id} does not have any active loan.") """
return cls.query.filter(
total_amount = ( cls.disburse_date.isnot(None),
cls.query cls.disburse_verify.is_(None)
.with_entities(func.coalesce(func.sum(cls.balance), 0.0)) ).order_by(cls.created_at.desc()).first()
.filter(
cls.customer_id == customer_id, @classmethod
cls.status != 'repaid' def get_customer_loans(cls, customer_id):
) """
.scalar() Get customer's active loans and sum by customer_id.
) """
customer_loans = cls.query.filter_by( customer_id = customer_id).all()
logger.info(f"Found {len(customer_loans)} active loans for customer ID: {customer_id} with total amount: {total_amount}") if not customer_loans:
raise ValueError(f"Customer with Id {customer_id} does not have any loan.")
return customer_loans, total_amount
total_amount = (
cls.query.with_entities(func.coalesce(func.sum(cls.balance), 0.0))
@classmethod .filter_by(customer_id=customer_id)
def update_status(cls, loan_id, status): .scalar()
""" )
Update the status of the loan record with the given loan_id.
""" logger.info(f"Found {len(customer_loans)} loans for customer ID: {customer_id} with total amount: {total_amount}")
try:
# Retrieve loan record return customer_loans, total_amount
loan = cls.query.get(loan_id) @classmethod
def get_customer_active_loans(cls, customer_id):
if not loan: """
raise ValueError(f"Loan with ID {loan_id} does not exist.") Get customer's active loans and sum by customer_id.
"""
if loan.status == status: customer_loans = cls.query.filter(
return loan.to_dict() # Still return the current state if no change cls.customer_id == customer_id,
cls.status != 'repaid'
# Update status and timestamp ).all()
loan.status = status
loan.updated_at = datetime.now(timezone.utc) if not customer_loans:
db.session.commit() raise ValueError(f"Customer with Id {customer_id} does not have any active loan.")
logger.info("Loan status updated and committed.") total_amount = (
return loan.to_dict() cls.query
.with_entities(func.coalesce(func.sum(cls.balance), 0.0))
except Exception as e: .filter(
db.session.rollback() cls.customer_id == customer_id,
logger.error(f"Error updating loan status: {e}") cls.status != 'repaid'
raise Exception(f"Error updating loan status: {str(e)}") )
.scalar()
)
@classmethod
def update_loan_balance(cls, loan_id, amount_collected): logger.info(f"Found {len(customer_loans)} active loans for customer ID: {customer_id} with total amount: {total_amount}")
"""
Update the balance of a loan after successful repayment. return customer_loans, total_amount
"""
try:
# Fetch the loan record @classmethod
loan = cls.query.get(loan_id) def update_status(cls, loan_id, status):
"""
if not loan: Update the status of the loan record with the given loan_id.
raise ValueError(f"Loan with ID {loan_id} does not exist.") """
try:
# Convert to Decimal and round to 2 decimal places # Retrieve loan record
amount_collected = Decimal(str(amount_collected)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) loan = cls.query.get(loan_id)
balance = Decimal(str(loan.balance or 0)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if not loan:
# Ensure valid repayment amount raise ValueError(f"Loan with ID {loan_id} does not exist.")
if amount_collected <= Decimal("0.00"):
raise ValueError("Repayment amount must be greater than zero.") if loan.status == status:
if balance <= Decimal("0.00"): return loan.to_dict() # Still return the current state if no change
raise ValueError("There is no balance for this loan.")
if amount_collected > balance: # Update status and timestamp
raise ValueError("Repayment amount exceeds current loan balance.") loan.status = status
loan.updated_at = datetime.now(timezone.utc)
# Deduct the amount from the current balance db.session.commit()
new_balance = balance - amount_collected
loan.balance = float(new_balance) logger.info("Loan status updated and committed.")
loan.updated_at = datetime.now(timezone.utc) return loan.to_dict()
db.session.commit()
except Exception as e:
logger.info(f"Loan balance updated for loan ID {loan_id}. New balance: {loan.balance}") db.session.rollback()
return loan.to_dict() logger.error(f"Error updating loan status: {e}")
raise Exception(f"Error updating loan status: {str(e)}")
except Exception as e:
db.session.rollback()
logger.error(f"Error updating loan balance: {e}") @classmethod
raise Exception(f"Error updating loan balance: {str(e)}") 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()
+127 -49
View File
@@ -1,49 +1,127 @@
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from os.path import devnull from os.path import devnull
from sqlalchemy.exc import IntegrityError
from app.extensions import db from app.extensions import db
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
class LoanCharge(db.Model): class LoanCharge(db.Model):
__tablename__ = 'loan_charges' __tablename__ = 'loan_charges'
id = db.Column(db.Integer, primary_key=True, autoincrement=True) id = db.Column(db.Integer, primary_key=True, autoincrement=True)
loan_id = db.Column(db.Integer, nullable=False) loan_id = db.Column(db.Integer, nullable=False)
transaction_id = db.Column(db.String(50), nullable=True) transaction_id = db.Column(db.String(50), nullable=True)
code = db.Column(db.String(50), nullable=False) code = db.Column(db.String(50), nullable=False)
amount = db.Column(db.Float, default=0.0) amount = db.Column(db.Float, default=0.0)
percent = db.Column(db.Float, default=0.0) percent = db.Column(db.Float, default=0.0)
description = db.Column(db.Text, nullable=True) description = db.Column(db.Text, nullable=True)
due = db.Column(db.Integer, nullable=False) due = db.Column(db.Integer, nullable=False)
due_date = db.Column(db.DateTime, nullable=True) due_date = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc)) 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)) updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
loan = relationship( loan = relationship(
"Loan", "Loan",
primaryjoin="LoanCharge.loan_id == Loan.id", primaryjoin="LoanCharge.loan_id == Loan.id",
foreign_keys=[loan_id], foreign_keys=[loan_id],
back_populates="loan_charges", back_populates="loan_charges",
) )
def __repr__(self): def __repr__(self):
return f"<LoanCharge {self.id} - Loan {self.loan_id} - {self.code}>" return f"<LoanCharge {self.id} - Loan {self.loan_id} - {self.code}>"
def to_dict(self): def to_dict(self):
""" """
Convert the Loan charge object to a dictionary format for JSON serialization. Convert the Loan charge object to a dictionary format for JSON serialization.
""" """
return { return {
'id': self.id, 'id': self.id,
'loanId': self.loan_id, 'loanId': self.loan_id,
'transactionId': self.transaction_id, 'transactionId': self.transaction_id,
'code': self.code, 'code': self.code,
'amount': self.amount, 'amount': self.amount,
'percent': self.percent, 'percent': self.percent,
'description': self.description, 'description': self.description,
'due': self.due 'due': self.due
} }
#get last penal
@classmethod @classmethod
def get_loan_charge_by_debt_id(cls, debt_id): def get_last_penal_no(cls, loan_id):
return cls.query.filter_by(loan_id=debt_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
+348
View File
@@ -0,0 +1,348 @@
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'<LoanRepaymentSchedule Loan:{self.loan_id} Installment:{self.installment_number}>'
@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()
# Calculate penal charge
@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
+259 -257
View File
@@ -1,258 +1,260 @@
from app.extensions import db from app.extensions import db
from datetime import datetime, timezone from datetime import datetime, timezone
from app.utils.logger import logger from app.utils.logger import logger
from app.enums.loan_status import LoanStatus from app.enums.loan_status import LoanStatus
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
class Repayment(db.Model): class Repayment(db.Model):
__tablename__ = "repayments" __tablename__ = "repayments"
id = db.Column( id = db.Column(
db.Integer, db.Integer,
primary_key=True, primary_key=True,
autoincrement=True, autoincrement=True,
) )
loan_id = db.Column(db.String(50), nullable=False) loan_id = db.Column(db.String(50), nullable=False)
customer_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) product_id = db.Column(db.String(20), nullable=True)
transaction_id = db.Column(db.String(50), nullable=False) transaction_id = db.Column(db.String(50), nullable=False)
initiated_by = db.Column(db.String(50), nullable=True) initiated_by = db.Column(db.String(50), nullable=True)
salary_amount = db.Column(db.Float, nullable=True, default=0.0) salary_amount = db.Column(db.Float, nullable=True, default=0.0)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc)) 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)) updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
repay_date = db.Column(db.DateTime, nullable=True) repay_date = db.Column(db.DateTime, nullable=True)
verify_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_result = db.Column(db.String(10), nullable=True)
repay_description = db.Column(db.String(100), nullable=True) repay_description = db.Column(db.String(100), nullable=True)
verify_result = db.Column(db.String(10), nullable=True) verify_result = db.Column(db.String(10), nullable=True)
verify_description = db.Column(db.String(100), nullable=True) verify_description = db.Column(db.String(100), nullable=True)
def __repr__(self): def __repr__(self):
return f'<Repayment {self.id}>' return f'<Repayment {self.id}>'
def to_dict(self): def to_dict(self):
""" """
Convert the Repayment object to a dictionary format for JSON serialization. Convert the Repayment object to a dictionary format for JSON serialization.
""" """
return { return {
'Id': self.id, 'Id': self.id,
"customerId": self.customer_id, "customerId": self.customer_id,
'loanId': self.loan_id, 'loanId': self.loan_id,
'productId': self.product_id, 'productId': self.product_id,
'repayResult': self.repay_result, 'repayResult': self.repay_result,
'repayDescription': self.repay_description, 'repayDescription': self.repay_description,
'verifyResult': self.verify_result, 'verifyResult': self.verify_result,
'verifyDescription': self.verify_description, 'verifyDescription': self.verify_description,
'transactionId': self.transaction_id, 'transactionId': self.transaction_id,
'initiatedBy':self.initiated_by, 'initiatedBy':self.initiated_by,
'salaryAmount':self.salary_amount, 'salaryAmount':self.salary_amount,
'repayDate': self.repay_date.isoformat() if self.repay_date else None, 'repayDate': self.repay_date.isoformat() if self.repay_date else None,
'VerifyDate': self.verify_date.isoformat() if self.verify_date else None, 'VerifyDate': self.verify_date.isoformat() if self.verify_date else None,
} }
@classmethod @classmethod
def create_repayment(cls, repayment_data): def create_repayment(cls, repayment_data):
if repayment_data["LoanStatus"] not in [LoanStatus.ACTIVE, LoanStatus.START_REPAY,LoanStatus.ACTIVE_PARTIAL]: 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']})") raise ValueError(f"Repayment cannot be processed. Loan status: ({repayment_data['LoanStatus']})")
repayment = cls( repayment = cls(
customer_id=repayment_data["customerId"], customer_id=repayment_data["customerId"],
loan_id=repayment_data["loanId"], loan_id=repayment_data["loanId"],
product_id=repayment_data["productId"], product_id=repayment_data["productId"],
transaction_id=repayment_data["transactionId"], transaction_id=repayment_data["transactionId"],
created_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc),
initiated_by= repayment_data["initiatedBy"], initiated_by= repayment_data["initiatedBy"],
salary_amount=repayment_data["salaryAmount"] salary_amount=repayment_data["salaryAmount"]
) )
try: try:
db.session.add(repayment) db.session.add(repayment)
db.session.commit() db.session.commit()
logger.info("Repayment record committed.") logger.info("Repayment record committed.")
return repayment return repayment
except IntegrityError as err: except IntegrityError as err:
logger.error(f"Database integrity error: {err}") logger.error(f"Database integrity error: {err}")
return {"error": "Integrity error", "details": str(err)} return {"error": "Integrity error", "details": str(err)}
@classmethod @classmethod
def add_repayment(cls, data: dict): def add_repayment(cls, data: dict):
""" """
Create and persist a new repayment record. Create and persist a new repayment record.
""" """
logger.info(f"Received repayment data: {data}") logger.info(f"Received repayment data: {data}")
try: try:
new_repayment = cls( new_repayment = cls(
loan_id=data["loanId"], loan_id=data["loanId"],
customer_id=data["customerId"], customer_id=data["customerId"],
product_id=data.get("productId"), product_id=data.get("productId"),
transaction_id=data["transactionId"], transaction_id=data["transactionId"],
initiated_by=data.get("initiatedBy"), initiated_by=data.get("initiatedBy"),
salary_amount=float(data.get("salaryAmount", 0.0)), salary_amount=float(data.get("salaryAmount", 0.0)),
repay_date=( repay_date=(
datetime.strptime(data["repayDate"], "%Y-%m-%d") datetime.strptime(data["repayDate"], "%Y-%m-%d")
.replace(tzinfo=timezone.utc) .replace(tzinfo=timezone.utc)
if data.get("repayDate") if data.get("repayDate")
else None else None
), ),
repay_result=data.get("repayResult"), repay_result=data.get("repayResult"),
repay_description=data.get("repayDescription"), repay_description=data.get("repayDescription"),
verify_result=data.get("verifyResult"), verify_result=data.get("verifyResult"),
verify_description=data.get("verifyDescription"), verify_description=data.get("verifyDescription"),
verify_date=( verify_date=(
datetime.strptime(data["verifyDate"], "%Y-%m-%d") datetime.strptime(data["verifyDate"], "%Y-%m-%d")
.replace(tzinfo=timezone.utc) .replace(tzinfo=timezone.utc)
if data.get("verifyDate") if data.get("verifyDate")
else None else None
), ),
) )
db.session.add(new_repayment) db.session.add(new_repayment)
db.session.commit() db.session.commit()
logger.info("Repayment record committed.") logger.info("Repayment record committed.")
return new_repayment return new_repayment
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
logger.error(f"Error adding repayment data: {e}") logger.error(f"Error adding repayment data: {e}")
raise raise
@classmethod @classmethod
def get_repayment_by_transaction_id(cls, transaction_id): def get_repayment_by_transaction_id(cls, transaction_id):
return cls.query.filter_by(transaction_id=transaction_id).first() return cls.query.filter_by(transaction_id=transaction_id).first()
@classmethod @classmethod
def get_repayment_by_id(cls, id): def get_repayment_by_id(cls, id):
return cls.query.filter_by(id=id).first() return cls.query.filter_by(id=id).first()
@classmethod @classmethod
def set_repay_date(cls, repayment_id, customer_id): def set_repay_date(cls, repayment_id, customer_id):
""" """
Update the repay date of the loan with the given loan_id. Update the repay date of the loan with the given loan_id.
""" """
# Retrieve repayment # Retrieve repayment
repayment = cls.query.get(repayment_id) repayment = cls.query.get(repayment_id)
if not repayment: if not repayment:
raise ValueError(f"repayment with ID {repayment_id} does not exist.") raise ValueError(f"repayment with ID {repayment_id} does not exist.")
# Check if customer_id matches # Check if customer_id matches
if repayment.customer_id != customer_id: if repayment.customer_id != customer_id:
raise ValueError(f"Customer ID {customer_id} does not match the repayment's customer ID.") raise ValueError(f"Customer ID {customer_id} does not match the repayment's customer ID.")
current_time = datetime.now() current_time = datetime.now()
logger.info(f"What is now ======= ==== ==> : {current_time}") logger.info(f"What is now ======= ==== ==> : {current_time}")
# Update repayment date # Update repayment date
repayment.repay_date = current_time repayment.repay_date = current_time
# Commit changes to database # Commit changes to database
try: try:
logger.info(f"Updating repay date for repayment ID {repayment_id} to {current_time}") logger.info(f"Updating repay date for repayment ID {repayment_id} to {current_time}")
db.session.commit() db.session.commit()
except Exception as e: return repayment.to_dict()
db.session.rollback() except Exception as e:
logger.error(f"Failed to update repay date: {e}") db.session.rollback()
raise logger.error(f"Failed to update repay date: {e}")
@classmethod raise e
def set_repay_verify_date(cls, repayment_id, customer_id): @classmethod
""" def set_repay_verify_date(cls, repayment_id, customer_id):
Update the repayment verify date of the loan with the given repayment_id. """
""" Update the repayment verify date of the loan with the given repayment_id.
# Retrieve repayment """
repayment = cls.query.get(repayment_id) # Retrieve repayment
repayment = cls.query.get(repayment_id)
if not repayment:
raise ValueError(f"repayment with ID {repayment_id} does not exist.") 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: # Check if customer_id matches
raise ValueError(f"Customer ID {customer_id} does not match the repayment's customer ID.") 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}") current_time = datetime.now()
# Update repayment verify_date logger.info(f"What is now ======= ==== ==> : {current_time}")
repayment.verify_date = current_time # Update repayment verify_date
repayment.verify_date = current_time
# Commit changes to database
try: # Commit changes to database
logger.info(f"Updating repay verify date for repayment ID {repayment_id} to {current_time}") try:
db.session.commit() logger.info(f"Updating repay verify date for repayment ID {repayment_id} to {current_time}")
except Exception as e: db.session.commit()
db.session.rollback() except Exception as e:
logger.error(f"Failed to update repay verify date: {e}") db.session.rollback()
raise logger.error(f"Failed to update repay verify date: {e}")
raise e
@classmethod
def set_repay_result(cls, repayment_id, result, description): @classmethod
""" def set_repay_result(cls, repayment_id, result, description):
Update the repayment result and description of the repayment with the given repayment_id. logger.info("repay result called")
""" """
# Retrieve loan Update the repayment result and description of the repayment with the given repayment_id.
repayment = cls.query.get(repayment_id) """
# Retrieve loan
if not repayment: repayment = cls.query.get(repayment_id)
raise ValueError(f"repayment with ID {repayment_id} does not exist.")
if not repayment:
# Update repayment result and description raise ValueError(f"repayment with ID {repayment_id} does not exist.")
repayment.repay_result = result
repayment.repay_description = description # Update repayment result and description
repayment.repay_result = result
# Commit changes to database repayment.repay_description = description
try:
logger.info(f"Updating repayment result for repayment ID {repayment_id} to {result} with description {description}") # Commit changes to database
db.session.commit() try:
except Exception as e: logger.info(f"Updating repayment result for repayment ID {repayment_id} to {result} with description {description}")
db.session.rollback() db.session.commit()
logger.error(f"Failed to update repayment result: {e}") except Exception as e:
raise db.session.rollback()
@classmethod logger.error(f"Failed to update repayment result: {e}")
def set_verify_date_result(cls, repayment_id, result, description): raise
""" @classmethod
Update the verify result and description of the repayment with the given repayment_id. def set_verify_date_result(cls, repayment_id, result, description):
""" """
# Retrieve repayment Update the verify result and description of the repayment with the given repayment_id.
repayment = cls.query.get(repayment_id) """
# Retrieve repayment
if not repayment: repayment = cls.query.get(repayment_id)
raise ValueError(f"repayment with ID {repayment_id} does not exist.")
if not repayment:
# Update disburse result and description raise ValueError(f"repayment with ID {repayment_id} does not exist.")
repayment.verify_result = result
repayment.verify_description = description # Update disburse result and description
repayment.verify_result = result
# Commit changes to database repayment.verify_description = description
try:
logger.info(f"Updating verify result for repayment ID {repayment_id} to {result} with description {description}") # Commit changes to database
db.session.commit() try:
except Exception as e: logger.info(f"Updating verify result for repayment ID {repayment_id} to {result} with description {description}")
db.session.rollback() db.session.commit()
logger.error(f"Failed to update verify result: {e}") except Exception as e:
raise db.session.rollback()
@classmethod logger.error(f"Failed to update verify result: {e}")
def get_latest_repayment_without_repay_date(cls): raise
""" @classmethod
Get the latest repayment without a repay date. def get_latest_repayment_without_repay_date(cls):
""" """
return cls.query.filter( Get the latest repayment without a repay date.
cls.repay_date.is_(None) """
).order_by(cls.created_at.desc()).first() return cls.query.filter(
@classmethod cls.repay_date.is_(None)
def get_latest_repayment_with_loanId(cls, loan_id): ).order_by(cls.created_at.desc()).first()
""" @classmethod
Get the latest repayment with loan Id. def get_latest_repayment_with_loanId(cls, loan_id):
""" """
return cls.query.filter( Get the latest repayment with loan Id.
cls.loan_id == loan_id """
).order_by(cls.created_at.desc()).first() return cls.query.filter(
cls.loan_id == loan_id
@classmethod ).order_by(cls.created_at.desc()).first()
def get_latest_loan_with_repay_date(cls):
""" @classmethod
Get the latest repayment with a repay date and no verification date. def get_latest_loan_with_repay_date(cls):
""" """
return cls.query.filter( Get the latest repayment with a repay date and no verification date.
cls.repay_date.isnot(None), """
cls.verify_date.is_(None) return cls.query.filter(
cls.repay_date.isnot(None),
cls.verify_date.is_(None)
).order_by(cls.created_at.desc()).first() ).order_by(cls.created_at.desc()).first()
+74 -74
View File
@@ -1,75 +1,75 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from app.extensions import db from app.extensions import db
from app.utils.logger import logger from app.utils.logger import logger
class RepaymentsData(db.Model): class RepaymentsData(db.Model):
__tablename__ = 'repayments_data' __tablename__ = 'repayments_data'
id = db.Column(db.Integer, primary_key=True, autoincrement=True) id = db.Column(db.Integer, primary_key=True, autoincrement=True)
transaction_id = db.Column(db.String(50), nullable=False) transaction_id = db.Column(db.String(50), nullable=False)
added_date = db.Column(db.DateTime(timezone=True), default=datetime.now(timezone.utc), 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_code = db.Column(db.String(10), nullable=True)
response_descr = db.Column(db.String(255), nullable=True) response_descr = db.Column(db.String(255), nullable=True)
fbn_transaction_id = 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) account_id = db.Column(db.String(50), nullable=True)
customer_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) repayment_amount = db.Column(db.Float, nullable=True)
amount_collected = db.Column(db.Float, nullable=True) amount_collected = db.Column(db.Float, nullable=True)
balance = db.Column(db.Float, nullable=True, default=0.0) balance = db.Column(db.Float, nullable=True, default=0.0)
def to_dict(self): def to_dict(self):
return { return {
"id": self.id, "id": self.id,
"transaction_id": self.transaction_id, "transaction_id": self.transaction_id,
"added_date": self.added_date.isoformat() if self.added_date else None, "added_date": self.added_date.isoformat() if self.added_date else None,
"response_code": self.response_code, "response_code": self.response_code,
"response_descr": self.response_descr, "response_descr": self.response_descr,
"customerId": self.customer_id, "customerId": self.customer_id,
"accountId": self.customer_id, "accountId": self.account_id,
"fbnTransactionId": self.fbn_transaction_id, "fbnTransactionId": self.fbn_transaction_id,
"repaymentAmount": self.repayment_amount, "repaymentAmount": self.repayment_amount,
"amountCollected": self.amount_collected, "amountCollected": self.amount_collected,
"balance": self.balance "balance": self.balance
} }
def __repr__(self): def __repr__(self):
return f"<RepaymentsData id={self.id}, transaction_id={self.transaction_id}>" return f"<RepaymentsData id={self.id}, transaction_id={self.transaction_id}>"
@classmethod @classmethod
def add_repayment_data(cls, data): def add_repayment_data(cls, data):
""" """
Add a new repayment data entry. Add a new repayment data entry.
""" """
try: try:
repayment_amount = float(data.get('repaymentAmount', 0.0)) repayment_amount = float(data.get('repaymentAmount', 0.0))
amount_collected = float(data.get('amountCollected', 0.0)) amount_collected = float(data.get('amountCollected', 0.0))
if amount_collected < 0 or repayment_amount < 0: if amount_collected < 0 or repayment_amount < 0:
raise ValueError("Amounts cannot be negative.") raise ValueError("Amounts cannot be negative.")
account_balance = round(repayment_amount - amount_collected, 2) account_balance = round(repayment_amount - amount_collected, 2)
new_data = cls( new_data = cls(
transaction_id=data.get('transactionId'), transaction_id=data.get('transactionId'),
response_code=data.get('responseCode'), response_code=data.get('responseCode'),
response_descr=data.get('responseDescr'), response_descr=data.get('responseDescr'),
fbn_transaction_id=data.get('fbnTransactionId'), fbn_transaction_id=data.get('fbnTransactionId'),
account_id=data.get('accountId'), account_id=data.get('accountId'),
customer_id=data.get('customerId'), customer_id=data.get('customerId'),
amount_collected=amount_collected, amount_collected=amount_collected,
repayment_amount=repayment_amount, repayment_amount=repayment_amount,
balance=account_balance, balance=account_balance,
) )
db.session.add(new_data) db.session.add(new_data)
db.session.commit() db.session.commit()
logger.info("Repayment data committed successfully") logger.info("Repayment data committed successfully")
return new_data return new_data
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
logger.error(f"Error adding repayment data: {e}") logger.error(f"Error adding repayment data: {e}")
raise Exception(f"Error adding repayment data: {str(e)}") raise Exception(f"Error adding repayment data: {str(e)}")
+98 -98
View File
@@ -1,98 +1,98 @@
from app.extensions import db from app.extensions import db
from datetime import datetime, timezone from datetime import datetime, timezone
from app.utils.logger import logger from app.utils.logger import logger
class Salary(db.Model): class Salary(db.Model):
__tablename__ = "salaries" __tablename__ = "salaries"
id = db.Column( id = db.Column(
db.Integer, db.Integer,
primary_key=True, primary_key=True,
autoincrement=True, autoincrement=True,
) )
customer_id = db.Column(db.String(50), nullable=False) customer_id = db.Column(db.String(50), nullable=False)
account_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) amount = db.Column(db.Float, nullable=True, default=0.0)
status = db.Column(db.String(20), nullable=True) status = db.Column(db.String(20), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.now) created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now) updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
salary_date = db.Column(db.DateTime, nullable=True) salary_date = db.Column(db.DateTime, nullable=True)
def __repr__(self): def __repr__(self):
return f'<Salary {self.id}>' return f'<Salary {self.id}>'
def to_dict(self): def to_dict(self):
""" """
Convert the Salary object to a dictionary format for JSON serialization. Convert the Salary object to a dictionary format for JSON serialization.
""" """
return { return {
'id': self.id, 'id': self.id,
'customerId': self.customer_id, 'customerId': self.customer_id,
'accountId' : self.account_id, 'accountId' : self.account_id,
'salaryAmount': self.amount, 'salaryAmount': self.amount,
'status': self.status, 'status': self.status,
'createdAt': self.created_at.isoformat() if self.created_at else None, 'createdAt': self.created_at.isoformat() if self.created_at else None,
'updatedAt': self.updated_at.isoformat() if self.updated_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, 'salaryDate': self.salary_date.isoformat() if self.salary_date else None,
} }
@classmethod @classmethod
def add_salary_data(cls, data): def add_salary_data(cls, data):
""" """
Add a new salary data entry. Add a new salary data entry.
""" """
logger.info(f"Received data:{data}") logger.info(f"Received data:{data}")
try: try:
new_data = cls( new_data = cls(
customer_id=data.get('customerId'), customer_id=data.get('customerId'),
amount=data.get('salaryAmount', 0.0), amount=data.get('salaryAmount', 0.0),
status='START', status='START',
salary_date = datetime.strptime(data.get('salaryDate'), "%Y-%m-%d").date() if data.get('salaryDate') else None, salary_date = datetime.strptime(data.get('salaryDate'), "%Y-%m-%d").date() if data.get('salaryDate') else None,
account_id=data.get('accountId') account_id=data.get('accountId')
) )
db.session.add(new_data) db.session.add(new_data)
db.session.commit() db.session.commit()
logger.info("Salary data has been committed.") logger.info("Salary data has been committed.")
return new_data return new_data
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
logger.info(f"error : {str(e)}") logger.info(f"error : {str(e)}")
raise Exception(f"Error adding salary data: {str(e)}") raise Exception(f"Error adding salary data: {str(e)}")
@classmethod @classmethod
def get_pending_salaries(cls): def get_pending_salaries(cls):
""" """
Retrieve all salary entries with status 'START', ordered by ID ascending. Retrieve all salary entries with status 'START', ordered by ID ascending.
""" """
try: try:
return cls.query.filter_by(status='START').order_by(cls.id.asc()).all() return cls.query.filter_by(status='START').order_by(cls.id.asc()).all()
except Exception as e: except Exception as e:
logger.error(f"Error fetching pending salaries: {str(e)}") logger.error(f"Error fetching pending salaries: {str(e)}")
return [] return []
@classmethod @classmethod
def update_status(cls, salary_id, status): def update_status(cls, salary_id, status):
""" """
Update the status of the salary record with the given salary_id. Update the status of the salary record with the given salary_id.
""" """
try: try:
# Retrieve salary record # Retrieve salary record
salary = cls.query.get(salary_id) salary = cls.query.get(salary_id)
if not salary: if not salary:
raise ValueError(f"Salary with ID {salary_id} does not exist.") raise ValueError(f"Salary with ID {salary_id} does not exist.")
if salary.status == status: if salary.status == status:
return salary.to_dict() # Still return the current state if no change return salary.to_dict() # Still return the current state if no change
# Update status and timestamp # Update status and timestamp
salary.status = status salary.status = status
salary.updated_at = datetime.now(timezone.utc) # Manually update timestamp if not auto-updating salary.updated_at = datetime.now(timezone.utc) # Manually update timestamp if not auto-updating
db.session.commit() db.session.commit()
logger.info("Salary status updated and committed.") logger.info("Salary status updated and committed.")
return salary.to_dict() return salary.to_dict()
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
logger.error(f"Error updating salary status: {e}") logger.error(f"Error updating salary status: {e}")
raise Exception(f"Error updating salary status: {str(e)}") raise Exception(f"Error updating salary status: {str(e)}")
+67 -39
View File
@@ -1,40 +1,68 @@
from app.extensions import db from app.extensions import db
from datetime import datetime, timezone from datetime import datetime, timezone
class Transaction(db.Model): from app.utils.logger import logger
__tablename__ = "transactions" from sqlalchemy import and_, or_, not_
id = db.Column( class Transaction(db.Model):
db.Integer, __tablename__ = "transactions"
primary_key=True,
autoincrement=True, id = db.Column(
) db.Integer,
transaction_id = db.Column(db.String(50), nullable=False) primary_key=True,
account_id = db.Column(db.String(50), nullable=True) autoincrement=True,
customer_id = db.Column(db.String(50), nullable=True) )
type = db.Column(db.String(50), nullable=False) transaction_id = db.Column(db.String(50), nullable=False)
channel = db.Column(db.String(50), nullable=False) account_id = db.Column(db.String(50), nullable=True)
phone_number = db.Column(db.String(50), nullable=True) customer_id = db.Column(db.String(50), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc)) type = db.Column(db.String(50), nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc)) channel = db.Column(db.String(50), nullable=False)
phone_number = db.Column(db.String(50), nullable=True)
def __repr__(self): created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
return f'<Transaction {self.id}>' updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
def to_dict(self): @classmethod
""" def create_transaction(cls, transaction_id, account_id, customer_id, type, channel):
Convert the Transaction object to a dictionary format for JSON serialization. logger.error(f"**Setting Transaction {transaction_id} for Type {type}")
""" if cls.query.filter( and_( cls.transaction_id ==transaction_id, cls.type==type) ).first():
return { logger.error(f"Transaction already exists for {type}")
'id': self.id, return '' # dont raise - do not crash beacause of this
'transaction_id': self.transaction_id,
'account_id': self.account_id, transaction = cls(
'customer_id': self.customer_id, transaction_id = transaction_id,
'phone_number':self.phone_number, customer_id = customer_id,
'type': self.type, account_id = account_id,
'channel': self.channel, type = type,
} channel = channel,
created_at=datetime.now(timezone.utc),
@classmethod updated_at=datetime.now(timezone.utc)
def get_transaction_by_transaction_id(cls, transaction_id): )
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'<Transaction {self.id}>'
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() return cls.query.filter_by(transaction_id=transaction_id).first()
+1 -1
View File
@@ -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) not_found, bad_request, success, created, updated)
+30 -30
View File
@@ -1,30 +1,30 @@
from flask import jsonify from flask import jsonify
from app.helpers.response_helper import ResponseHelper from app.helpers.response_helper import ResponseHelper
def method_not_allowed(error): def method_not_allowed(error):
return ResponseHelper.method_not_allowed(message="Method Not Allowed") return ResponseHelper.method_not_allowed(message="Method Not Allowed")
def not_found(error): def not_found(error):
return ResponseHelper.not_found(message="URL Not Found") return ResponseHelper.not_found(message="URL Not Found")
def bad_request(error): def bad_request(error):
return ResponseHelper.bad_request(message="Bad Request") return ResponseHelper.bad_request(message="Bad Request")
def unsupported_media_type(error): def unsupported_media_type(error):
return ResponseHelper.error(message="Unsupported Media Type", status_code=415) return ResponseHelper.error(message="Unsupported Media Type", status_code=415)
def success(data): def success(data):
return ResponseHelper.success(data=data) return ResponseHelper.success(data=data)
def created(data): def created(data):
return ResponseHelper.created(data=data) return ResponseHelper.created(data=data)
def updated(data): def updated(data):
return ResponseHelper.updated(data=data) return ResponseHelper.updated(data=data)
+2 -2
View File
@@ -1,2 +1,2 @@
from .authentication import auth_bp from .authentication import auth_bp
from .autocall import autocall_bp from .autocall import autocall_bp
+137 -83
View File
@@ -1,83 +1,137 @@
from flask import Blueprint, request, jsonify, current_app from flask import Blueprint, request, jsonify, current_app
import requests import requests
from app.utils.auth import get_headers from app.extensions import db
from app.config import settings from sqlalchemy import text
from app.utils.logger import logger from app.utils.auth import get_headers
from app.config import settings
auth_bp = Blueprint("auth", __name__) from app.utils.logger import logger
from app.integrations.bank_service import BankService
BASE_URL = settings.BANK_CALL_BASE_URL
auth_bp = Blueprint("auth", __name__)
@auth_bp.route("/health", methods=["GET"]) BASE_URL = settings.BANK_CALL_BASE_URL
def health():
logger.info("Health check endpoint called")
return jsonify({"status": "Up"}) @auth_bp.route("/health", methods=["GET"])
def health():
logger.info("Health check endpoint called")
@auth_bp.route("/login", methods=["POST"]) errors = [] # collect all errors
def login():
data = request.get_json() try:
api_url = f"{BASE_URL}/login" # Detect database type
dialect = db.engine.dialect.name.lower()
response = requests.post(api_url, json=data) logger.info(f"Database dialect detected: {dialect}")
if response.status_code == 200:
return jsonify(response.json()), 200 # Build correct query based on DB type
return jsonify({"error": "Invalid credentials"}), response.status_code if "oracle" in dialect:
query = text("SELECT 1 FROM dual")
else:
@auth_bp.route("/status-call", methods=["POST"]) query = text("SELECT 1")
def status_call():
data = request.get_json() # Test database connection
api_url = f"{BASE_URL}/StatusCall" try:
db.session.execute(query)
# response = requests.post(api_url, json=data, headers=get_headers()) logger.info("Database connection successful.")
# return jsonify(response.json()), response.status_code except Exception as db_err:
response = { logger.error(f"Database connection failed: {str(db_err)}")
"transactionId": "24110114545374721", errors.append(f"Database connection failed: {str(db_err)}")
"data": {
"transactionId": "241101", # Check Bank Service health
"providedAmount": 1000, try:
"collectedAmount": 0, bank_response = BankService.health_check()
"resultCode": "00", logger.info(f"Bank Service health check response: {bank_response}")
"resultDescription": "Loan Provision is successful", except Exception as bank_err:
}, logger.error(f"Bank Service health check failed: {str(bank_err)}")
"resultCode": "00", errors.append(f"Bank Service health check failed: {str(bank_err)}")
"resultDescription": "SUCCESS",
} # Build final response
if errors:
return jsonify(response), 200 return jsonify({
"status": "error",
"database": dialect,
@auth_bp.route("/sms", methods=["POST"]) "errors": errors
def sms(): }), 500
data = request.get_json()
api_url = f"{BASE_URL}/SMS" return jsonify({
"status": "success",
# response = requests.post(api_url, json=data, headers=get_headers()) "database": dialect,
# return jsonify(response.json()), response.status_code "db_status": "connected",
response = { "bank_service_status": "operational",
"data": "", "message": "All systems operational"
"statusCode": 200, }), 200
"IsSuccessful": True,
"errorMessage": None, except Exception as e:
} logger.exception("Unexpected error during health check")
return jsonify({
return jsonify(response), 200 "status": "error",
"errors": [str(e)]
}), 500
@auth_bp.route("/bulk-sms", methods=["POST"])
def bulk_sms():
data = request.get_json()
api_url = f"{BASE_URL}/BulkSMS" @auth_bp.route("/login", methods=["POST"])
def login():
# response = requests.post(api_url, json=data, headers=get_headers()) data = request.get_json()
# return jsonify(response.json()), response.status_code api_url = f"{BASE_URL}/login"
response = {
"data": "", response = requests.post(api_url, json=data)
"statusCode": 200, if response.status_code == 200:
"IsSuccessful": True, return jsonify(response.json()), 200
"errorMessage": None, return jsonify({"error": "Invalid credentials"}), response.status_code
}
return jsonify(response), 200 @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
+684 -236
View File
@@ -1,236 +1,684 @@
from flask import Blueprint, request, jsonify, current_app import time as time_module
import requests from flask import Blueprint, request, jsonify, current_app
from app.config import settings import requests
from app.helpers.response_helper import ResponseHelper from app.extensions import db
from app.utils.auth import get_headers from app.config import settings
from app.utils.logger import logger from app.helpers.response_helper import ResponseHelper
from app.integrations.simbrella import SimbrellaClient from app.helpers.collect_loan_helper import CollectLoanHelper
from app.services.loan import LoanService from app.utils.auth import get_headers
from app.services.repayment import RepaymentService from app.utils.logger import logger
from app.services.salary import SalaryService from app.integrations.simbrella import SimbrellaClient
from app.enums.loan_status import LoanStatus from app.services.loan import LoanService
from app.services.repayment import RepaymentService
autocall_bp = Blueprint("autocall", __name__) from app.services.salary import SalaryService
from app.services.loan_repayment_schedule import LoanRepaymentScheduleService
@autocall_bp.route("/refresh-verify-disbursement", methods=["GET"]) from app.services.loan_charge import LoanChargesService
def verify_transaction(): from app.enums.loan_status import LoanStatus
logger.info(f"Calling VerifyTransaction Components") from app.enums.repayment_schedule_status import RepaymentScheduleStatus
from app.utils.mail import send_report_email, get_report_data
loan = LoanService.get_latest_loan_with_disburse_date() from datetime import datetime, timezone, timedelta
if not loan: from app.config import settings
logger.info(f"No loan found without disbursement date")
return 0 autocall_bp = Blueprint("autocall", __name__)
logger.info(f"Calling VerifyTransaction endpoint with data: {loan}")
loan_data = loan.to_dict()
@autocall_bp.route("/refresh-verify-disbursement", methods=["GET"])
data = { def verify_transaction():
"transactionId": loan_data.get('transactionId'), logger.info(f"Calling VerifyTransaction Components")
"FbnTransactionId": loan_data.get('transactionId'),
"debtId": str(loan_data.get('debtId')), loan = LoanService.get_latest_loan_with_disburse_date()
"customerId": loan_data.get('customerId'), if not loan:
"accountId": loan_data.get('accountId'), logger.info(f"No loan found without disbursement date")
"productId": str(loan_data.get('productId', "")), return 0
"provideAmount": loan_data.get('currentLoanAmount'), logger.info(f"Calling VerifyTransaction endpoint with data: {loan}")
} loan_data = loan.to_dict()
response = SimbrellaClient.verify_transaction(data)
return response data = {
"transactionId": loan_data.get('transactionId'),
@autocall_bp.route("/refresh-disbursement", methods=["GET"]) "fbnTransactionId": loan_data.get('transactionId'),
def disbursement(): "debtId": str(loan_data.get('debtId')),
# data = request.json() "customerId": loan_data.get('customerId'),
logger.info(f"Calling Disbursement Components") "accountId": loan_data.get('accountId'),
loan = LoanService.get_latest_loan_without_disburse_date() "productId": str(loan_data.get('productId', "")),
if not loan: "provideAmount": loan_data.get('currentLoanAmount'),
logger.info(f"No loan found without disbursement date") }
return 0 response = SimbrellaClient.verify_transaction(data)
logger.info(f"Calling DisburseLoan endpoint with data: {loan}") return response
loan_data = loan.to_dict()
@autocall_bp.route("/refresh-disbursement", methods=["GET"])
data = { def disbursement():
"transactionId": loan_data.get('transactionId'), # data = request.json()
"FbnTransactionId": loan_data.get('transactionId'), logger.info(f"Calling Disbursement Components")
"debtId": str(loan_data.get('debtId')), loan = LoanService.get_latest_loan_without_disburse_date()
"customerId": loan_data.get('customerId'), if not loan:
"accountId": loan_data.get('accountId'), logger.info(f"No loan found without disbursement date")
"productId": str(loan_data.get('productId', "")), return 0
"provideAmount": loan_data.get('currentLoanAmount'), logger.info(f"Calling DisburseLoan endpoint with data: {loan}")
} loan_data = loan.to_dict()
response = SimbrellaClient.disburse_loan(data)
return response data = {
"transactionId": loan_data.get('transactionId'),
"FbnTransactionId": loan_data.get('transactionId'),
@autocall_bp.route("/refresh-verify-collection", methods=["GET"]) "debtId": str(loan_data.get('debtId')),
def refresh_verify_collection(): "customerId": loan_data.get('customerId'),
data = request.get_json() "accountId": loan_data.get('accountId'),
logger.info(f"Calling Verify Collection") "productId": str(loan_data.get('productId', "")),
"provideAmount": loan_data.get('currentLoanAmount'),
response = SimbrellaClient.collect_loan(data) }
response = SimbrellaClient.disburse_loan(data)
return response return response
@autocall_bp.route("/refresh-collection", methods=["GET"]) @autocall_bp.route("/retry-disbursement", methods=["POST"])
def refresh_collection(): def retry_disbursement():
#data = request.get_json() try:
logger.info(f"Calling Collection ") data = request.get_json()
#grab the last repayments with repay date is none logger.info(f"Retry Transaction ID Data Received for :::: {data}")
repayment = RepaymentService.get_latest_repayment_without_repay_date()
#repayment = RepaymentService.get_latest_repayment_with_loanId(13735) transactionId = data["transactionId"]
if not repayment: logger.info(f"Starting Transaction ID Data Received for :::: {transactionId}")
logger.info(f"No repayment found without repay date")
return 0 logger.info(f"Calling Disbursement Components for Retry Transaction ID Data Received for :::: {transactionId}")
logger.info(f"Calling repay loan endpoint with data: {repayment}") loan = LoanService.get_loan_by_transaction_id(transactionId)
repayment_data = repayment.to_dict() if not loan:
logger.info(f"here is the dict form of repayment {repayment_data}") logger.info(f"No loan found without disbursement date")
return 0
data = { logger.info(f"Calling DisburseLoan endpoint with data: {loan}")
"transactionId": repayment_data['transactionId'], loan_data = loan.to_dict()
"debtId": repayment_data['loanId'],
"customerId": repayment_data['customerId'], data = {
"productId": repayment_data['productId'], "transactionId": loan_data.get('transactionId'),
"Id":repayment_data['Id'] "FbnTransactionId": loan_data.get('transactionId'),
} "debtId": str(loan_data.get('debtId')),
logger.info(f"Data being sent to Simbrella: {data}") "customerId": loan_data.get('customerId'),
logger.info(f"calling simbrella") "accountId": loan_data.get('accountId'),
response = SimbrellaClient.collect_loan_user_initiated(data) "productId": str(loan_data.get('productId', "")),
"provideAmount": loan_data.get('currentLoanAmount'),
return response }
response = SimbrellaClient.disburse_loan(data)
@autocall_bp.route("/payment-callback", methods=["POST"]) # return response
def payment_callback(): logger.info(f"Retry Disbursement Transaction ID Result Received for :::: {response}")
data = request.get_json() return ResponseHelper.success(message="Retry Disbursement Request Sent Successfully", status_code=200)
logger.info(f"Calling Callback Components") except Exception as e:
logger.error(f"Failed to call retry disbursement {data}: {e}")
response = SimbrellaClient.payment_callback(data)
return response
@autocall_bp.route("/penal-charge", methods=["POST"])
def penal_charge(): @autocall_bp.route("/direct/loan", methods=["POST"])
data = request.get_json() def direct_loan():
logger.info(f"Calling Penal Charge Endpoint") data = request.get_json()
logger.info(f"Data received: {data}")
try:
response = SimbrellaClient.penal_charge(data[0]) REQUIRED_KEYS = [
return response "transactionId"
except Exception as e: ]
logger.error(f"Error in Penal Charge: {e}")
return ResponseHelper.error("Penal charge failed") # 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:
@autocall_bp.route("/analytic-salary-detect", methods=["POST"]) logger.warning(f"Missing required keys: {missing_keys}")
def salary_detect(): return jsonify({
payload = request.get_json() "status": "error",
logger.info("Calling Salary Detect endpoint") "message": f"Missing required fields: {', '.join(missing_keys)}"
}), 400
if payload is None:
logger.warning("No payload received in request") # Check if the loan exists
return ResponseHelper.error("Missing request payload", status_code=400) logger.info(f"Checking if loan with transaction id {data['transactionId']} exists")
transaction_id = data["transactionId"].strip()
# Step 1: Try to add new salary data
try: loan = LoanService.get_loan_by_transaction_id(transaction_id=transaction_id)
new_salary = SalaryService.add_salary_data(payload) # TODO - This will come as array of salaries - not just one if not loan:
if new_salary: logger.warning(f"Loan with transaction id {transaction_id} does not exist")
logger.info(f"Salary added: {new_salary.id}") return jsonify({
except Exception as e: "status": "error",
logger.error(f"Failed to save salary: {e}") "message": f"Loan with transaction id {transaction_id} does not exist"
}), 400
# Step 2: Try processing salary list
try: loan_data = loan.to_dict()
process_salary_list()
except Exception as e: # Prevent double disbursement
logger.exception("Unhandled error occurred while processing salary list {e}") if loan_data.get('disburseDate') is not None:
return ResponseHelper.error("Failed to process salary list", status_code=500, error=str(e)) return jsonify({
"status": "error",
logger.info("Finished processing List") "message": f"Loan with transaction id {data['transactionId']} has already been processed"
return ResponseHelper.success([], "AutoCall Add Salary Successful") }), 400
data_to_process = {
@autocall_bp.route("/analytic-salary-process", methods=["POST"]) "transactionId": loan_data.get('transactionId'),
def salary_process(): "FbnTransactionId": loan_data.get('transactionId'),
response = process_salary_list() "debtId": str(loan_data.get('debtId')),
return ResponseHelper.success([], "AutoCall Successful") "customerId": loan_data.get('customerId'),
"accountId": loan_data.get('accountId'),
"productId": str(loan_data.get('productId', "")),
def process_salary_list(): "provideAmount": loan_data.get('currentLoanAmount'),
# Step 1: Get all pending salaries }
pending_salaries = SalaryService.get_pending_salaries() response = SimbrellaClient.disburse_loan(data_to_process)
if not pending_salaries: return response
logger.info("No pending salaries found")
return ResponseHelper.success([], "No pending salaries") @autocall_bp.route("/direct/repayment", methods=["POST"])
def direct_repayment():
logger.info(f"Found {len(pending_salaries)} pending salaries to process") data = request.get_json()
logger.info(f"Data received: {data}")
for pending_salary in pending_salaries:
logger.info(f"Processing salary ID: {pending_salary.id}") REQUIRED_KEYS = ["transactionId"]
# Step 2: Update salary status to PROCESSING # Check for missing keys
try: missing_keys = [key for key in REQUIRED_KEYS if key not in data or data[key] is None]
SalaryService.update_status(pending_salary.id, "PROCESSING") if missing_keys:
except Exception as e: logger.warning(f"Missing required keys: {missing_keys}")
logger.warning(f"Could not update status for salary ID {pending_salary.id}: {e}") return jsonify({
continue "status": "error",
"message": f"Missing required fields: {', '.join(missing_keys)}"
# Step 3: Get customer's active loans }), 400
try:
loans, total_amount = LoanService.get_customer_active_loans(pending_salary.customer_id) # Check if the loan exists
if not loans: logger.info(f"Checking if loan with transaction id {data['transactionId']} exists")
logger.warning(f"No loans found for customer ID: {pending_salary.customer_id}") loan = LoanService.get_loan_by_transaction_id(transaction_id=data['transactionId'])
continue if not loan:
except Exception as e: logger.info(f"Loan with transaction id {data['transactionId']} does not exist")
logger.error(f"Error fetching loans for customer ID {pending_salary.customer_id}: {e}") return jsonify({
continue "status": "error",
"message": f"Loan with transaction id {data['transactionId']} does not exist"
# Step 4: Create repayments for each loan }), 400
for loan in loans:
logger.info(f"Processing Loan ID: {loan.id}") loan_data = loan.to_dict()
try:
repayment_data = { # check if loan has been repaid
"customerId": loan.customer_id, if loan_data.get("status") == LoanStatus.REPAID and loan_data.get("balance") <= 0:
"loanId": loan.id, logger.info(f"Loan with Id {loan_data.get('debtId')} has been repaid")
"productId": loan.product_id, return jsonify({
"transactionId": loan.transaction_id, "status": "error",
"initiatedBy": "SALARY_DETECT", "message": f"loan with Id {loan_data.get('debtId')} has been repaid"
"salaryAmount": pending_salary.amount, }), 400
"LoanStatus": loan.status,
}
repayment_data = {
logger.info(f"Creating repayment with data: {repayment_data}") "customerId": loan_data.get("customerId"),
repayment = RepaymentService.create_repayment(repayment_data) "loanId": loan_data.get("debtId"),
"productId": loan_data.get("productId"),
if not repayment: "transactionId": loan_data.get("transactionId"),
logger.error(f"Repayment creation failed for loan ID {loan.id}") "initiatedBy": "USER INITIATED",
continue "salaryAmount": 0,
"LoanStatus": loan_data.get("status"),
# Update loan status to START_REPAY }
try:
LoanService.update_status(loan_id=loan.id, status=LoanStatus.START_REPAY) logger.info(f"Creating repayment with data: {repayment_data}")
except Exception as e:
logger.error(f"Failed to update loan status for loan ID {loan.id}: {e}") try:
repayment = RepaymentService.create_repayment(repayment_data)
logger.info(f"Created repayment ID: {repayment.id}") logger.info(f"Repayment created: {repayment}")
except Exception as e: except Exception as e:
logger.error(f"Error creating repayment for loan ID {loan.id}: {e}") db.session.rollback()
continue logger.error(f"Repayment creation raised exception: {e}")
return jsonify({
# Step 5: Call Simbrella to collect loan "status": "error",
"message": "Failed to create repayment"
try: }), 500
simbrella_response = SimbrellaClient.collect_loan_user_salary_detect(repayment.to_dict())
if not repayment or (isinstance(repayment, dict) and "error" in repayment):
if isinstance(simbrella_response, tuple): db.session.rollback()
simbrella_response, status_code = simbrella_response logger.error(f"Repayment creation failed for loan ID {loan_data.get('debtId')}: {repayment}")
logger.warning(f"Simbrella returned tuple: status={status_code}, response={simbrella_response}")
try:
if isinstance(simbrella_response, dict): if loan_data.get('status') == LoanStatus.ACTIVE:
status = simbrella_response.get("status") LoanService.update_status(loan_id=loan_data.get('debtId'), status=LoanStatus.START_REPAY)
if status != "success": except Exception as e:
logger.warning(f"Simbrella call failed for repayment ID {repayment.id}: {simbrella_response}") db.session.rollback()
else: logger.error(f"Failed to update loan status for loan ID {loan_data.get('debtId')}: {e}")
logger.warning(f"Unexpected Simbrella response type: {type(simbrella_response)}") repayment_data_dict = repayment.to_dict()
except Exception as e:
logger.error(f"Failed to call Simbrella for repayment ID {repayment.id}: {e}") data_to_process = {
"transactionId": repayment_data_dict['transactionId'],
"debtId": repayment_data_dict['loanId'],
logger.info(f"Finished processing salary ID: {pending_salary.id}") "customerId": repayment_data_dict['customerId'],
"productId": repayment_data_dict['productId'],
return ResponseHelper.success([], "Processed all pending salaries") "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}")
+148 -101
View File
@@ -1,101 +1,148 @@
from app.models import Loan, LoanCharge from app.models import Loan, LoanCharge
from app.utils.logger import logger
class LoanService: from app.enums.loan_status import LoanStatus
from decimal import Decimal, ROUND_HALF_UP
@classmethod from app.services.loan_repayment_schedule import LoanRepaymentScheduleService
def get_loan_by_transaction_id(cls, transaction_id):
""" class LoanService:
Get the loan by transaction ID
""" @classmethod
return Loan.get_loan_by_transaction_id(transaction_id) def get_loan_by_transaction_id(cls, transaction_id):
@classmethod """
def get_loan_by_loan_id(cls, loan_id): Get the loan by transaction ID
""" """
Get the loan by ID return Loan.get_loan_by_transaction_id(transaction_id)
""" @classmethod
return Loan.get_loan_by_loan_id(loan_id) def get_loan_by_loan_id(cls, loan_id):
"""
Get the loan by ID
@classmethod """
def get_loan_by_debt_id(cls, debt_id): return Loan.get_loan_by_loan_id(loan_id)
"""
Get the loan by transaction ID
""" @classmethod
return Loan.get_loan_by_debt_id(debt_id) def get_loan_by_debt_id(cls, debt_id):
"""
Get the loan by transaction ID
@classmethod """
def get_loan_charge_by_debt_id(cls, debt_id): return Loan.get_loan_by_debt_id(debt_id)
"""
Get the loan charge by debt ID
""" @classmethod
return LoanCharge.get_loan_charge_by_debt_id(debt_id) def get_loan_charge_by_debt_id(cls, debt_id):
"""
@classmethod Get the loan charge by debt ID
def set_disbursement_date(cls, loan_id, customer_id): """
""" return LoanCharge.get_loan_charge_by_debt_id(debt_id)
Update the disbursement status of the loan with the given loan_id.
""" @classmethod
return Loan.set_disbursement_date(loan_id, customer_id) def set_disbursement_date(cls, loan_id, customer_id):
@classmethod """
def set_disburse_verify_date(cls, loan_id, customer_id): Update the disbursement status of the loan with the given loan_id.
""" """
Update the disburse verify date of the loan with the given loan_id. return Loan.set_disbursement_date(loan_id, customer_id)
""" @classmethod
return Loan.set_disburse_verify_date(loan_id, customer_id) def set_disburse_verify_date(cls, loan_id, customer_id):
"""
@classmethod Update the disburse verify date of the loan with the given loan_id.
def set_disbursement_result(cls, loan_id, result, description): """
""" return Loan.set_disburse_verify_date(loan_id, customer_id)
Update the disbursement result of the loan with the given loan_id.
""" @classmethod
return Loan.set_disbursement_result(loan_id, result, description) def set_disbursement_result(cls, loan_id, result, description):
@classmethod """
def set_disburse_verify_result(cls, loan_id, result, description): Update the disbursement result of the loan with the given loan_id.
""" """
Update the disburse verify result of the loan with the given loan_id. return Loan.set_disbursement_result(loan_id, result, description)
"""
return Loan.set_disburse_verify_result(loan_id, result, description) @classmethod
def set_disbursement_loan_description(cls,loan_id,description):
@classmethod
def get_latest_loan_without_disburse_date(cls): return Loan.set_disbursement_message(loan_id, description)
"""
Get the latest loan without a disbursement date.
""" @classmethod
return Loan.get_latest_loan_without_disburse_date() def set_disburse_verify_result(cls, loan_id, result, description):
"""
@classmethod Update the disburse verify result of the loan with the given loan_id.
def get_latest_loan_with_disburse_date(cls): """
""" return Loan.set_disburse_verify_result(loan_id, result, description)
Get the latest loan without a disbursement date.
""" @classmethod
return Loan.get_latest_loan_with_disburse_date() def get_latest_loan_without_disburse_date(cls):
"""
@classmethod Get the latest loan without a disbursement date.
def get_customer_loans(cls, customer_id): """
""" return Loan.get_latest_loan_without_disburse_date()
Get customer's active loans by customer_id.
""" @classmethod
def get_latest_loan_with_disburse_date(cls):
return Loan.get_customer_loans(customer_id=customer_id) """
@classmethod Get the latest loan without a disbursement date.
def get_customer_active_loans(cls, customer_id): """
""" return Loan.get_latest_loan_with_disburse_date()
Get customer's active loans by customer_id.
""" @classmethod
def get_customer_loans(cls, customer_id):
return Loan.get_customer_active_loans(customer_id=customer_id) """
Get customer's active loans by customer_id.
@classmethod """
def update_status(cls, loan_id, status):
""" return Loan.get_customer_loans(customer_id=customer_id)
Update the status of the loan with the given loan_id. @classmethod
""" def get_customer_active_loans(cls, customer_id):
# Retrieve loan """
return Loan.update_status(loan_id, status) Get customer's active loans by customer_id.
@classmethod """
def update_loan_balance(cls,loan_id,amount_collected):
""" return Loan.get_customer_active_loans(customer_id=customer_id)
update the loan balance after successful repayment
""" @classmethod
return Loan.update_loan_balance(loan_id,amount_collected) 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
+13
View File
@@ -0,0 +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):
return LoanCharge.get_penal_charges_by_loan_id(loan_id)
+136
View File
@@ -0,0 +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}")
+77 -77
View File
@@ -1,78 +1,78 @@
from app.models import Repayment from app.models import Repayment
class RepaymentService: class RepaymentService:
@staticmethod @staticmethod
def get_repayment_by_transaction_id(transaction_id): def get_repayment_by_transaction_id(transaction_id):
""" """
Get the repayment by transaction ID Get the repayment by transaction ID
""" """
return Repayment.get_repayment_by_transaction_id(transaction_id) return Repayment.get_repayment_by_transaction_id(transaction_id)
@staticmethod @staticmethod
def get_repayment_by_id(id): def get_repayment_by_id(id):
""" """
Get the repayment by ID Get the repayment by ID
""" """
return Repayment.get_repayment_by_id(id) return Repayment.get_repayment_by_id(id)
@classmethod @classmethod
def set_repay_date(cls, repayment_id, customer_id): def set_repay_date(cls, repayment_id, customer_id):
""" """
Update the repay status of the repayment with the given repayment_id. Update the repay status of the repayment with the given repayment_id.
""" """
return Repayment.set_repay_date(repayment_id, customer_id) return Repayment.set_repay_date(repayment_id, customer_id)
@classmethod @classmethod
def set_repay_verify_date(cls, repayment_id, customer_id): def set_repay_verify_date(cls, repayment_id, customer_id):
""" """
Update the verify date of the repayment with the given repayment_id. Update the verify date of the repayment with the given repayment_id.
""" """
return Repayment.set_repay_verify_date(repayment_id, customer_id) return Repayment.set_repay_verify_date(repayment_id, customer_id)
@classmethod @classmethod
def set_repay_result(cls, repayment_id, result, description): def set_repay_result(cls, repayment_id, result, description):
""" """
Update the repay result of the repayment with the given repayment_id. Update the repay result of the repayment with the given repayment_id.
""" """
return Repayment.set_repay_result(repayment_id, result, description) return Repayment.set_repay_result(repayment_id, result, description)
@classmethod @classmethod
def set_verify_date_result(cls, repayment_id, result, description): def set_verify_date_result(cls, repayment_id, result, description):
""" """
Update the verify result of the repayment with the given repayment_id. Update the verify result of the repayment with the given repayment_id.
""" """
return Repayment.set_verify_date_result(repayment_id, result, description) return Repayment.set_verify_date_result(repayment_id, result, description)
@classmethod @classmethod
def get_latest_repayment_without_repay_date(cls): def get_latest_repayment_without_repay_date(cls):
""" """
Get the latest repayment without a repay date. Get the latest repayment without a repay date.
""" """
return Repayment.get_latest_repayment_without_repay_date() return Repayment.get_latest_repayment_without_repay_date()
@classmethod @classmethod
def get_latest_repayment_with_loanId(cls,loan_id): def get_latest_repayment_with_loanId(cls,loan_id):
""" """
Get the latest repayment with loan id. Get the latest repayment with loan id.
""" """
return Repayment.get_latest_repayment_with_loanId(loan_id) return Repayment.get_latest_repayment_with_loanId(loan_id)
@classmethod @classmethod
def get_latest_loan_with_repay_date(cls): def get_latest_loan_with_repay_date(cls):
""" """
Get the latest repayment with a repay date and no verification date. Get the latest repayment with a repay date and no verification date.
""" """
return Repayment.get_latest_loan_with_repay_date() return Repayment.get_latest_loan_with_repay_date()
@classmethod @classmethod
def add_repayment(cls, data): def add_repayment(cls, data):
""" """
Add a new repayment entry. Add a new repayment entry.
""" """
return Repayment.add_repayment(data) return Repayment.add_repayment(data)
@classmethod @classmethod
def create_repayment(cls, repayment_data): def create_repayment(cls, repayment_data):
""" """
Add a new repayment entry. Add a new repayment entry.
""" """
return Repayment.create_repayment(repayment_data) return Repayment.create_repayment(repayment_data)
+10 -10
View File
@@ -1,11 +1,11 @@
from app.models import RepaymentsData from app.models import RepaymentsData
class RepaymentService: class RepaymentService:
@classmethod @classmethod
def add_repayment_data(cls,data): def add_repayment_data(cls,data):
""" """
Add a new repayment data entry. Add a new repayment data entry.
""" """
return RepaymentsData.add_repayment_data(data) return RepaymentsData.add_repayment_data(data)
+23 -23
View File
@@ -1,24 +1,24 @@
from app.models import Salary from app.models import Salary
class SalaryService: class SalaryService:
@classmethod @classmethod
def add_salary_data(cls,data): def add_salary_data(cls,data):
""" """
Add a new salary data entry. Add a new salary data entry.
""" """
return Salary.add_salary_data(data) return Salary.add_salary_data(data)
@classmethod @classmethod
def get_pending_salaries(cls): def get_pending_salaries(cls):
""" """
Get the pending salary for a given customer. Get the pending salary for a given customer.
""" """
return Salary.get_pending_salaries() return Salary.get_pending_salaries()
@classmethod @classmethod
def update_status(cls, salary_id, status): def update_status(cls, salary_id, status):
""" """
Update the status of the salary with the given salary_id. Update the status of the salary with the given salary_id.
""" """
return Salary.update_status(salary_id, status) return Salary.update_status(salary_id, status)
+17 -11
View File
@@ -1,11 +1,17 @@
from app.models import Transaction from app.models import Transaction
class TransactionService: class TransactionService:
@staticmethod @staticmethod
def get_transaction_by_transaction_id(transaction_id): def get_transaction_by_transaction_id(transaction_id):
""" """
Get the transaction by ID Get the transaction by ID
""" """
return Transaction.get_transaction_by_transaction_id(transaction_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)
+45 -9
View File
@@ -1,9 +1,45 @@
from app.config import settings from app.config import settings
import requests
from app.utils.logger import logger
def get_headers():
return { def get_headers():
"Content-Type": "application/json", BANK_CALL_BASE_URL = settings.BANK_CALL_BASE_URL
"x-api-key": settings.BANK_CALL_API_KEY, BANK_CALL_AUTH_ENDPOINT = settings.BANK_CALL_AUTH_ENDPOINT
"App-Id": settings.BANK_CALL_APP_ID, 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
+15 -15
View File
@@ -1,16 +1,16 @@
def preprocess_loan_charges_data(data): def preprocess_loan_charges_data(data):
""" """
Preprocesses the data into a dictionary for efficient lookups by 'code'. Preprocesses the data into a dictionary for efficient lookups by 'code'.
Args: Args:
data: A list of dictionaries. data: A list of dictionaries.
Returns: Returns:
A dictionary where keys are 'code' values and values are the corresponding dictionaries from the input data. 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. If multiple items have the same code, the last one encountered will be stored.
""" """
preprocessed = {} preprocessed = {}
for item in data: for item in data:
if 'code' in item: if 'code' in item:
preprocessed[item['code']] = item preprocessed[item['code']] = item
return preprocessed return preprocessed
+13 -13
View File
@@ -1,13 +1,13 @@
import logging import logging
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s", format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[ handlers=[
# logging.StreamHandler(), # Log to console # logging.StreamHandler(), # Log to console
logging.FileHandler("app.log", mode='a') # Log to file logging.FileHandler("app.log", mode='a') # Log to file
] ]
) )
logger = logging.getLogger("DetectionService") logger = logging.getLogger("DetectionService")
+40
View File
@@ -0,0 +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"<h3>Loan Report Summary</h3>{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"
+32 -32
View File
@@ -1,32 +1,32 @@
version: "3.8" version: "3.8"
services: services:
flask: flask:
build: . build: .
env_file: env_file:
- .env - .env
ports: ports:
- "5000:5000" - "5000:5000"
environment: environment:
- FLASK_APP=app.py - FLASK_APP=app.py
- FLASK_RUN_HOST=0.0.0.0 - FLASK_RUN_HOST=0.0.0.0
volumes: volumes:
- .:/app - .:/app
restart: always restart: always
networks: networks:
- digital - digital
swagger: swagger:
image: swaggerapi/swagger-ui:v5.1.0 image: swaggerapi/swagger-ui:v5.1.0
ports: ports:
- "9000:8080" - "9000:8080"
volumes: volumes:
- ./openapi.yml:/usr/local/openapi.yml - ./openapi.yml:/usr/local/openapi.yml
environment: environment:
- SWAGGER_JSON=/usr/local/openapi.yml - SWAGGER_JSON=/usr/local/openapi.yml
restart: always restart: always
networks: networks:
- digital - digital
networks: networks:
digital: digital:
driver: bridge driver: bridge
+259 -203
View File
@@ -1,203 +1,259 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Event Manager API title: Event Manager API
description: The documentation for Event Manager API description: The documentation for Event Manager API
version: 1.0.0 version: 1.0.0
contact: contact:
name: API Support name: API Support
email: support@example.com email: support@example.com
license: license:
name: MIT name: MIT
url: https://opensource.org/licenses/MIT url: https://opensource.org/licenses/MIT
servers: servers:
- url: http://localhost:5000 - url: http://localhost:5000
description: Local development server description: Local development server
- url: http://www.simbrellang.net:5000 - url: http://www.simbrellang.net:5000
description: Remote Temporary development server description: Remote Temporary development server
- url: https://event-core.simbrellang.net - url: https://event-core.simbrellang.net
description: Remote development server description: Remote development server
- url: http://10.2.249.133:5000
paths: description: Internal development server
/health:
get: paths:
summary: Returns a health message /health:
responses: get:
200: summary: Returns a health message
description: A successful response responses:
/status-call: 200:
post: description: A successful response
summary: Perform a status call /status-call:
requestBody: post:
required: true summary: Perform a status call
content: requestBody:
application/json: required: true
schema: content:
type: object application/json:
properties: schema:
requestId: type: object
type: string properties:
example: "R02802" requestId:
countryCode: type: string
type: string example: "R02802"
example: "NGR" countryCode:
transactionId: type: string
type: string example: "NGR"
example: "Tr201712RK9232P115" transactionId:
debtId: type: string
type: string example: "Tr201712RK9232P115"
example: "173021" debtId:
transactionType: type: string
type: string example: "173021"
example: "Disbursement" transactionType:
customerId: type: string
type: string example: "Disbursement"
example: "CN621868" customerId:
responses: type: string
200: example: "CN621868"
description: A successful response responses:
/sms: 200:
post: description: A successful response
summary: Send a SMS /sms:
requestBody: post:
required: true summary: Send a SMS
content: requestBody:
application/json: required: true
schema: content:
type: object application/json:
properties: schema:
text: type: object
type: string properties:
example: "This is a test message for SMS request method." text:
dest: type: string
type: string example: "This is a test message for SMS request method."
example: "+2348039409144" dest:
unicode: type: string
type: boolean example: "+2348039409144"
example: false unicode:
responses: type: boolean
200: example: false
description: A successful response responses:
/bulk-sms: 200:
post: description: A successful response
summary: Send a bulk SMS /bulk-sms:
requestBody: post:
required: true summary: Send a bulk SMS
content: requestBody:
application/json: required: true
schema: content:
type: array application/json:
items: schema:
type: object type: array
properties: items:
text: type: object
type: string properties:
example: "This is a test message for SMS request method." text:
dest: type: string
type: string example: "This is a test message for SMS request method."
example: "+2348039409144" dest:
unicode: type: string
type: boolean example: "+2348039409144"
example: true unicode:
responses: type: boolean
200: example: true
description: A successful response responses:
/autocall/refresh-verify-disbursement: 200:
get: description: A successful response
summary: Refresh the disbursement to verify /autocall/refresh-verify-disbursement:
responses: get:
200: summary: Refresh the disbursement to verify
description: A successful response responses:
/autocall/refresh-disbursement: 200:
get: description: A successful response
summary: Refresh the disbursement /autocall/refresh-disbursement:
responses: get:
200: summary: Refresh the disbursement
description: A successful response responses:
/autocall/refresh-verify-collection: 200:
get: description: A successful response
summary: Refresh the disbursement to verify /autocall/refresh-verify-collection:
responses: get:
200: summary: Refresh the disbursement to verify
description: A successful response responses:
/autocall/refresh-collection: 200:
get: description: A successful response
summary: Refresh the disbursement /autocall/refresh-collection:
responses: get:
200: summary: Refresh the disbursement
description: A successful response responses:
/autocall/payment-callback: 200:
get: description: A successful response
summary: The Payment callback /autocall/payment-callback:
responses: get:
200: summary: The Payment callback
description: A successful response responses:
/autocall/penal-charge: 200:
post: description: A successful response
summary: Penal Charge Request /autocall/penal-charge:
requestBody: post:
required: true summary: Penal Charge Request
content: requestBody:
application/json: required: true
schema: content:
type: array application/json:
items: schema:
type: object type: array
properties: items:
transactionId: type: object
type: string properties:
example: "T004" transactionId:
fbnTransactionId: type: string
type: string example: "T004"
example: "Tr201712RK9232P115" fbnTransactionId:
debtId: type: string
type: string example: "Tr201712RK9232P115"
example: "273194670" debtId:
customerId: type: string
type: string example: "273194670"
example: "CN621868" customerId:
accountId: type: string
type: string example: "CN621868"
example: "2017821799" accountId:
penalCharge: type: string
type: number example: "2017821799"
example: "1.2" penalCharge:
lienAmount: type: number
type: number example: "1.2"
example: "101.2" lienAmount:
countryId: type: number
type: string example: "101.2"
example: "01" countryId:
comment: type: string
type: string example: "01"
example: "Testing PenalCharge" comment:
responses: type: string
200: example: "Testing PenalCharge"
description: A successful response responses:
/autocall/analytic-salary-detect: 200:
post: description: A successful response
summary: Salary Detect Endpoint /autocall/analytic-salary-detect:
requestBody: post:
required: true summary: Salary Detect Endpoint
content: requestBody:
application/json: required: true
schema: content:
type: object application/json:
properties: schema:
customerId: type: object
type: string properties:
example: "CN621868" customerId:
accountId: type: string
type: string example: "CN621868"
example: "OP621868" accountId:
status: type: string
type: string example: "OP621868"
salaryAmount: status:
type: number type: string
example: 200000 salaryAmount:
salaryDate: type: number
type: string example: 200000
example: "2025-01-01" salaryDate:
responses: type: string
200: example: "2025-01-01"
description: A successful response 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
+16 -13
View File
@@ -1,13 +1,16 @@
# Flask and Extensions # Flask and Extensions
Flask==2.3.3 Flask==2.3.3
Flask-Marshmallow==0.15.0 Flask-Marshmallow==0.15.0
marshmallow==3.19.0 marshmallow==3.19.0
Flask-Cors==3.0.10 Flask-Cors==3.0.10
gunicorn gunicorn
requests requests
confluent-kafka==1.9.2 confluent-kafka==1.9.2
flask-sqlalchemy flask-sqlalchemy
psycopg2-binary psycopg2-binary
alembic alembic
python-dateutil python-dateutil
oracledb oracledb
Flask-Mail==0.10.0
pandas==2.1.3
openpyxl==3.1.5
+36 -36
View File
@@ -1,37 +1,37 @@
import threading import threading
from app import create_app from app import create_app
from app.integrations import KafkaIntegration from app.integrations import KafkaIntegration
from app.config import settings from app.config import settings
from app.utils.logger import logger from app.utils.logger import logger
app = create_app() app = create_app()
kafka = KafkaIntegration() kafka = KafkaIntegration()
def start_kafka_consumer(app): def start_kafka_consumer(app):
with app.app_context(): with app.app_context():
logger.info("Starting Kafka consumer...") logger.info("Starting Kafka consumer...")
while True: while True:
try: try:
message = kafka.receive_messages( message = kafka.receive_messages(
topics=settings.KAFKA_TOPICS, timeout=settings.KAFKA_TIMEOUT topics=settings.KAFKA_TOPICS, timeout=settings.KAFKA_TIMEOUT
) )
if message: if message:
logger.info(f"Processed message: {message}") logger.info(f"Processed message: {message}")
else: else:
logger.info("No message received within timeout") logger.info("No message received within timeout")
except Exception as e: except Exception as e:
logger.error(f"Error while receiving message: {e}") logger.error(f"Error while receiving message: {e}")
if __name__ != "__main__": if __name__ != "__main__":
# Expose WSGI app instance for Gunicorn # Expose WSGI app instance for Gunicorn
wsgi_app = app wsgi_app = app
# Start kafka in a thread # Start kafka in a thread
threading.Thread(target=start_kafka_consumer, args=(app,), daemon=True).start() threading.Thread(target=start_kafka_consumer, args=(app,), daemon=True).start()