6 Commits

Author SHA1 Message Date
ameye e27fb6d627 Merge branch 'penal_check' of DigiFi/digifi-EventManager into master 2026-04-07 11:10:39 +00:00
Chinenye Nmoh c32c2502cc added failed loans endpoint 2026-04-05 16:53:02 +01:00
CHIEFSOFT\ameye f1db12c7f2 Autocall Commnet Format 2026-04-01 20:13:57 -04:00
CHIEFSOFT\ameye 3032e6f0b9 Autocall Cleanup 2026-04-01 20:12:33 -04:00
ameye addb89af60 Merge branch 'penal_check' of DigiFi/digifi-EventManager into master 2026-03-17 11:21:21 +00:00
Chinenye Nmoh 9386573dfd added penal check query 2026-03-17 12:16:11 +01:00
49 changed files with 4688 additions and 4518 deletions
+15 -15
View File
@@ -1,16 +1,16 @@
KAFKA_TIMEOUT=1000.0
KAFKA_BROKER="10.20.30.50:9092"
KAFKA_TOPICS=PROCESS_PAYMENT,LOAN_REPAYMENT
# DATABASE_USER=firstadvance
# DATABASE_PASSWORD=FirstAdvance!
# DATABASE_HOST=10.20.30.60
# DATABASE_PORT=5432
# DATABASE_NAME=firstadvancedev
DATABASE_USER=system
DATABASE_PASSWORD=FIRSTADV_PASS
DATABASE_HOST=10.10.33.65
DATABASE_PORT=1521
KAFKA_TIMEOUT=1000.0
KAFKA_BROKER="10.20.30.50:9092"
KAFKA_TOPICS=PROCESS_PAYMENT,LOAN_REPAYMENT
# DATABASE_USER=firstadvance
# DATABASE_PASSWORD=FirstAdvance!
# DATABASE_HOST=10.20.30.60
# DATABASE_PORT=5432
# DATABASE_NAME=firstadvancedev
DATABASE_USER=system
DATABASE_PASSWORD=FIRSTADV_PASS
DATABASE_HOST=10.10.33.65
DATABASE_PORT=1521
DATABASE_SID=FREE
+15 -15
View File
@@ -1,16 +1,16 @@
KAFKA_TIMEOUT=1000.0
KAFKA_BROKER="dev-events.simbrellang.net:9085"
KAFKA_TOPICS=PROCESS_PAYMENT,LOAN_REPAYMENT
# DATABASE_USER=firstadvance
# DATABASE_PASSWORD=FirstAdvance!
# DATABASE_HOST=dev-data.simbrellang.net
# DATABASE_PORT=10532
# DATABASE_NAME=firstadvancedev
DATABASE_USER=system
DATABASE_PASSWORD=FIRSTADV_PASS
DATABASE_HOST=10.10.33.65
DATABASE_PORT=1521
KAFKA_TIMEOUT=1000.0
KAFKA_BROKER="dev-events.simbrellang.net:9085"
KAFKA_TOPICS=PROCESS_PAYMENT,LOAN_REPAYMENT
# DATABASE_USER=firstadvance
# DATABASE_PASSWORD=FirstAdvance!
# DATABASE_HOST=dev-data.simbrellang.net
# DATABASE_PORT=10532
# DATABASE_NAME=firstadvancedev
DATABASE_USER=system
DATABASE_PASSWORD=FIRSTADV_PASS
DATABASE_HOST=10.10.33.65
DATABASE_PORT=1521
DATABASE_SID=FREE
+5 -5
View File
@@ -1,6 +1,6 @@
.vscode/
__pycache__/
*/__pycache__/
.env
app.log
.vscode/
__pycache__/
*/__pycache__/
.env
app.log
.idea/
+21 -21
View File
@@ -1,21 +1,21 @@
# Use Python base image
FROM python:3.10
# Set working directory
WORKDIR /app
# Copy files to container
COPY . /app
# Install dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Expose port 5000
EXPOSE 5000
# Set environment variables
ENV FLASK_APP=app.py
ENV FLASK_RUN_HOST=0.0.0.0
# Run the application
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "--timeout", "120", "wsgi:wsgi_app"]
# Use Python base image
FROM python:3.10
# Set working directory
WORKDIR /app
# Copy files to container
COPY . /app
# Install dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Expose port 5000
EXPOSE 5000
# Set environment variables
ENV FLASK_APP=app.py
ENV FLASK_RUN_HOST=0.0.0.0
# Run the application
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "--timeout", "120", "wsgi:wsgi_app"]
+75 -75
View File
@@ -1,75 +1,75 @@
# Flask API with Swagger in Docker
This repository contains a Flask API with Swagger documentation, running inside Docker containers.
## Getting Started
### Prerequisites
Ensure you have the following installed:
- [Docker](https://www.docker.com/get-started)
- [Docker Compose](https://docs.docker.com/compose/install/)
### Clone the Repository
```sh
git clone <your-repo-url>
cd <your-repo-folder>
```
### Setup and Run the Application
#### 1. Build and Start the Containers
```sh
docker-compose up --build -d
```
This will:
- Build the Flask API container.
- Start the Swagger UI container separately.
#### 2. Verify the Containers are Running
```sh
docker ps
```
#### 3. Access the API
- The **Flask API** will be available at:
```
http://localhost:5000
```
- The **Swagger UI** will be available at:
```
http://localhost:9000
```
You can interact with the API documentation and test endpoints from here.
## Making API Requests
You can send requests using tools like `curl` or Postman:
```sh
curl -X GET http://localhost:5000/hello
```
## Stopping the Application
To stop the running containers, use:
```sh
docker-compose down
```
## Troubleshooting
- If there are permission issues, try running Docker commands with `sudo`.
- If ports are in use, change them in `docker-compose.yml`.
- To rebuild the containers, use:
```sh
docker-compose up --build -d
```
# Flask API with Swagger in Docker
This repository contains a Flask API with Swagger documentation, running inside Docker containers.
## Getting Started
### Prerequisites
Ensure you have the following installed:
- [Docker](https://www.docker.com/get-started)
- [Docker Compose](https://docs.docker.com/compose/install/)
### Clone the Repository
```sh
git clone <your-repo-url>
cd <your-repo-folder>
```
### Setup and Run the Application
#### 1. Build and Start the Containers
```sh
docker-compose up --build -d
```
This will:
- Build the Flask API container.
- Start the Swagger UI container separately.
#### 2. Verify the Containers are Running
```sh
docker ps
```
#### 3. Access the API
- The **Flask API** will be available at:
```
http://localhost:5000
```
- The **Swagger UI** will be available at:
```
http://localhost:9000
```
You can interact with the API documentation and test endpoints from here.
## Making API Requests
You can send requests using tools like `curl` or Postman:
```sh
curl -X GET http://localhost:5000/hello
```
## Stopping the Application
To stop the running containers, use:
```sh
docker-compose down
```
## Troubleshooting
- If there are permission issues, try running Docker commands with `sudo`.
- If ports are in use, change them in `docker-compose.yml`.
- To rebuild the containers, use:
```sh
docker-compose up --build -d
```
+7 -7
View File
@@ -1,7 +1,7 @@
from app import create_app
app = create_app()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)
from app import create_app
app = create_app()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)
+36 -36
View File
@@ -1,36 +1,36 @@
from flask import Flask
from flask_mail import Mail
from flask_cors import CORS
from app.config import Config
from app.routes import auth_bp, autocall_bp
from app.response import (method_not_allowed, unsupported_media_type, not_found, bad_request)
from app.extensions import db, mail
def create_app():
"""Factory function to create a Flask app instance"""
app = Flask(__name__)
# Load configuration
app.config.from_object(Config)
# Setup CORS
CORS(app)
# Initialize Flask-Mail
mail.init_app(app)
# Register blueprints
app.register_blueprint(auth_bp)
app.register_blueprint(autocall_bp, url_prefix="/autocall")
# Error Handlers
app.register_error_handler(405, method_not_allowed)
app.register_error_handler(415, unsupported_media_type)
app.register_error_handler(404, not_found)
app.register_error_handler(400, bad_request)
# Database
db.init_app(app)
return app
from flask import Flask
from flask_mail import Mail
from flask_cors import CORS
from app.config import Config
from app.routes import auth_bp, autocall_bp
from app.response import (method_not_allowed, unsupported_media_type, not_found, bad_request)
from app.extensions import db, mail
def create_app():
"""Factory function to create a Flask app instance"""
app = Flask(__name__)
# Load configuration
app.config.from_object(Config)
# Setup CORS
CORS(app)
# Initialize Flask-Mail
mail.init_app(app)
# Register blueprints
app.register_blueprint(auth_bp)
app.register_blueprint(autocall_bp, url_prefix="/autocall")
# Error Handlers
app.register_error_handler(405, method_not_allowed)
app.register_error_handler(415, unsupported_media_type)
app.register_error_handler(404, not_found)
app.register_error_handler(400, bad_request)
# Database
db.init_app(app)
return app
+91 -83
View File
@@ -1,83 +1,91 @@
import os
from datetime import timedelta
class Config:
"""Base configuration for Flask app"""
SECRET_KEY = os.getenv("SECRET_KEY", "supersecretkey")
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your_jwt_secret")
DEBUG = True
KAFKA_BROKER = os.getenv("KAFKA_BROKER", "dev-events.simbrellang.net:9085")
KAFKA_TOPICS = [topic.strip() for topic in os.getenv("KAFKA_TOPICS", "PROCESS_PAYMENT,LOAN_REPAYMENT").split(",") if topic.strip()]
KAFKA_TIMEOUT = float( os.getenv("KAFKA_TIMEOUT", 1000.0) )
JWT_ACCESS_TOKEN_EXPIRES = os.getenv("JWT_ACCESS_TOKEN_EXPIRES", timedelta(hours=1))
JWT_REFRESH_TOKEN_EXPIRES = os.getenv(
"JWT_REFRESH_TOKEN_EXPIRES", timedelta(days=30)
)
BANK_CALL_APP_ID = os.getenv("BANK_CALL_APP_ID", "app1")
BANK_CALL_API_KEY = os.getenv("BANK_CALL_API_KEY", "testtest-api-key-12345")
BANK_CALL_BASIC_AUTH_USERNAME = os.environ.get(
"BANK_CALL_BASIC_AUTH_USERNAME", "simbrella"
)
BANK_CALL_BASIC_AUTH_PASSWORD = os.environ.get(
"BANK_CALL_BASIC_AUTH_PASSWORD", "G7$k9@pL2!qR"
)
DATABASE_USER = os.getenv("DATABASE_USER")
DATABASE_PASSWORD = os.getenv("DATABASE_PASSWORD")
DATABASE_HOST = os.getenv("DATABASE_HOST")
DATABASE_NAME = os.getenv("DATABASE_NAME")
DATABASE_PORT = os.getenv("DATABASE_PORT", 10532)
DATABASE_SID = os.environ.get("DATABASE_SID", "FREE")
DNS = f"(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST={DATABASE_HOST})(PORT={DATABASE_PORT}))(CONNECT_DATA=(SID={DATABASE_SID})))"
SQLALCHEMY_DATABASE_URI_INTERNAL = (f"oracle+oracledb://{DATABASE_USER}:{DATABASE_PASSWORD}@{DNS}")
#SQLALCHEMY_DATABASE_URI_INTERNAL = (f"oracle+oracledb://{DATABASE_USER}:{DATABASE_PASSWORD}@{DNS}")
SQLALCHEMY_DATABASE_URI = os.getenv("SQLALCHEMY_DATABASE_URI_FULL", SQLALCHEMY_DATABASE_URI_INTERNAL)
# SQLALCHEMY_DATABASE_URI = f"postgresql+psycopg2://{DATABASE_USER}:{DATABASE_PASSWORD}@{DATABASE_HOST}:{DATABASE_PORT}/{DATABASE_NAME}"
SQLALCHEMY_TRACK_MODIFICATIONS = False
# SQLALCHEMY_ECHO = True
OVERRIDE_COLLECTION_TRANCATION_ID = int(os.getenv("OVERRIDE_COLLECTION_TRANCATION_ID", 100))
MAIL_SERVER = os.getenv('MAIL_SERVER','smtp.zoho.com')
MAIL_PORT = os.getenv('MAIL_PORT', 587)
MAIL_USERNAME = os.getenv('MAIL_USERNAME', 'firstadvance@dynamikservices.tech')
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
MAIL_USE_TLS = os.getenv('MAIL_USE_TLS', 'True').lower() in ('true', '1', 'yes')
MAIL_USE_SSL = os.getenv('MAIL_USE_SSL', 'False').lower() in ('true', '1', 'yes')
MAIL_DEFAULT_SENDER = ('FirstAdvance', 'firstadvance@dynamikservices.tech')
MAIL_RECEIVER= os.getenv('MAIL_RECEIVER', 'chinenyeumeaku@gmail.com,umeakuchinenye@gmail.com')
# Processing Overdue LOANS sections
OVERDUE_LOAN_BATCH_SIZE = int(os.getenv("OVERDUE_LOAN_BATCH_SIZE", 10))
OVERDUE_LOAN_DELAY_SECONDS = int(os.getenv("OVERDUE_LOAN_DELAY_SECONDS", 5))
OVERDUE_LOAN_BATCH_DELAY_SECONDS = int(
os.getenv("OVERDUE_LOAN_BATCH_DELAY_SECONDS", 5)
)
OVERDUE_GRACE_PERIOD_DAYS = int(os.getenv("OVERDUE_GRACE_PERIOD_DAYS", 30))
OVERDUE_PROCESSING_LIST_LIMIT = int(os.getenv("OVERDUE_PROCESSING_LIST_LIMIT", 100))
PENAL_CHARGE_PERCENTAGE = os.getenv("PENAL_CHARGE_PERCENTAGE", 1)
PENAL_CHARGE_INTERVAL_DAYS = os.getenv("PENAL_CHARGE_INTERVAL_DAYS", 30)
PENAL_CHARGE_MAXIMUM_COUNT = os.getenv("PENAL_CHARGE_MAXIMUM_COUNT", 6)
BANK_CALL_API_TIME_OUT = os.getenv("BANK_CALL_API_TIME_OUT", 100)
BANK_CALL_BASE_URL = os.getenv("BANK_CALL_BASE_URL", "https://bank-emulator.dev.simbrellang.net/api")
BANK_CALL_SMS_BASE_URL= os.getenv("BANK_CALL_SMS_BASE_URL","https://first-advance-middleware-develop.fbn-devops-dev-asenv.appserviceenvironment.net/SMS")
BANK_CALL_DISBURSE_LOAN_ENDPOINT = os.getenv("BANK_CALL_DISBURSE_LOAN_ENDPOINT","/DisburseLoan")
BANK_CALL_COLLECT_LOAN_ENDPOINT = os.getenv("BANK_CALL_COLLECT_LOAN_ENDPOINT","/CollectLoan")
BANK_CALL_TRANSACTION_VERIFY = os.getenv("BANK_CALL_TRANSACTION_VERIFY", "/TransactionVerify")
BANK_HEALTH_CHECK_ENDPOINT = os.getenv("BANK_HEALTH_CHECK_ENDPOINT", "/system-health-check")
BANK_CALL_AUTH_ENDPOINT = os.getenv("BANK_CALL_AUTH_ENDPOINT", "/Auth/generate-token")
BANK_GRANT_TYPE = os.getenv("BANK_GRANT_TYPE", "password")
TEST_NO = os.getenv("TEST_NO", "2347038224367")
settings = Config()
import os
from datetime import timedelta
class Config:
"""Base configuration for Flask app"""
SECRET_KEY = os.getenv("SECRET_KEY", "supersecretkey")
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your_jwt_secret")
DEBUG = True
KAFKA_BROKER = os.getenv("KAFKA_BROKER", "dev-events.simbrellang.net:9085")
KAFKA_TOPICS = [topic.strip() for topic in os.getenv("KAFKA_TOPICS", "PROCESS_PAYMENT,LOAN_REPAYMENT").split(",") if topic.strip()]
KAFKA_TIMEOUT = float( os.getenv("KAFKA_TIMEOUT", 1000.0) )
JWT_ACCESS_TOKEN_EXPIRES = os.getenv("JWT_ACCESS_TOKEN_EXPIRES", timedelta(hours=1))
JWT_REFRESH_TOKEN_EXPIRES = os.getenv(
"JWT_REFRESH_TOKEN_EXPIRES", timedelta(days=30)
)
BANK_CALL_APP_ID = os.getenv("BANK_CALL_APP_ID", "app1")
BANK_CALL_API_KEY = os.getenv("BANK_CALL_API_KEY", "testtest-api-key-12345")
BANK_CALL_BASIC_AUTH_USERNAME = os.environ.get(
"BANK_CALL_BASIC_AUTH_USERNAME", "simbrella"
)
BANK_CALL_BASIC_AUTH_PASSWORD = os.environ.get(
"BANK_CALL_BASIC_AUTH_PASSWORD", "G7$k9@pL2!qR"
)
DATABASE_USER = os.getenv("DATABASE_USER")
DATABASE_PASSWORD = os.getenv("DATABASE_PASSWORD")
DATABASE_HOST = os.getenv("DATABASE_HOST")
DATABASE_NAME = os.getenv("DATABASE_NAME")
DATABASE_PORT = os.getenv("DATABASE_PORT", 10532)
DATABASE_SID = os.environ.get("DATABASE_SID", "FREE")
DNS = f"(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST={DATABASE_HOST})(PORT={DATABASE_PORT}))(CONNECT_DATA=(SID={DATABASE_SID})))"
SQLALCHEMY_DATABASE_URI_INTERNAL = (f"oracle+oracledb://{DATABASE_USER}:{DATABASE_PASSWORD}@{DNS}")
#SQLALCHEMY_DATABASE_URI_INTERNAL = (f"oracle+oracledb://{DATABASE_USER}:{DATABASE_PASSWORD}@{DNS}")
SQLALCHEMY_DATABASE_URI = os.getenv("SQLALCHEMY_DATABASE_URI_FULL", SQLALCHEMY_DATABASE_URI_INTERNAL)
# SQLALCHEMY_DATABASE_URI = f"postgresql+psycopg2://{DATABASE_USER}:{DATABASE_PASSWORD}@{DATABASE_HOST}:{DATABASE_PORT}/{DATABASE_NAME}"
SQLALCHEMY_TRACK_MODIFICATIONS = False
# SQLALCHEMY_ECHO = True
OVERRIDE_COLLECTION_TRANCATION_ID = int(os.getenv("OVERRIDE_COLLECTION_TRANCATION_ID", 100))
MAIL_SERVER = os.getenv('MAIL_SERVER','smtp.zoho.com')
MAIL_PORT = os.getenv('MAIL_PORT', 587)
MAIL_USERNAME = os.getenv('MAIL_USERNAME', 'firstadvance@dynamikservices.tech')
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
MAIL_USE_TLS = os.getenv('MAIL_USE_TLS', 'True').lower() in ('true', '1', 'yes')
MAIL_USE_SSL = os.getenv('MAIL_USE_SSL', 'False').lower() in ('true', '1', 'yes')
MAIL_DEFAULT_SENDER = ('FirstAdvance', 'firstadvance@dynamikservices.tech')
MAIL_RECEIVER= os.getenv('MAIL_RECEIVER', 'chinenyeumeaku@gmail.com,umeakuchinenye@gmail.com')
# Processing Overdue LOANS sections
OVERDUE_LOAN_BATCH_SIZE = int(os.getenv("OVERDUE_LOAN_BATCH_SIZE", 10))
OVERDUE_LOAN_DELAY_SECONDS = int(os.getenv("OVERDUE_LOAN_DELAY_SECONDS", 5))
OVERDUE_LOAN_BATCH_DELAY_SECONDS = int(
os.getenv("OVERDUE_LOAN_BATCH_DELAY_SECONDS", 5)
)
OVERDUE_GRACE_PERIOD_DAYS = int(os.getenv("OVERDUE_GRACE_PERIOD_DAYS", 30))
OVERDUE_PROCESSING_LIST_LIMIT = int(os.getenv("OVERDUE_PROCESSING_LIST_LIMIT", 100))
PENAL_CHARGE_PERCENTAGE = os.getenv("PENAL_CHARGE_PERCENTAGE", 1)
PENAL_CHARGE_INTERVAL_DAYS = os.getenv("PENAL_CHARGE_INTERVAL_DAYS", 30)
PENAL_CHARGE_MAXIMUM_COUNT = os.getenv("PENAL_CHARGE_MAXIMUM_COUNT", 6)
#processing failed disbursement sections
FAILED_DISBURSEMENT_BATCH_SIZE = int(os.getenv("FAILED_DISBURSEMENT_BATCH_SIZE", 10))
FAILED_DISBURSEMENT_DELAY_SECONDS = int(os.getenv("FAILED_DISBURSEMENT_DELAY_SECONDS", 5))
FAILED_DISBURSEMENT_BATCH_DELAY_SECONDS = int(
os.getenv("FAILED_DISBURSEMENT_BATCH_DELAY_SECONDS", 5)
)
FAILED_RETRY_TIME_INTERVAL_SECONDS = int(os.getenv("FAILED_RETRY_TIME_INTERVAL_SECONDS", 86400)) #24 hours = 86400 seconds
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 -3
View File
@@ -1,3 +1,3 @@
from .transaction_type import TransactionType
from .loan_status import LoanStatus
from .repayment_schedule_status import RepaymentScheduleStatus
from .transaction_type import TransactionType
from .loan_status import LoanStatus
from .repayment_schedule_status import RepaymentScheduleStatus
+9 -8
View File
@@ -1,8 +1,9 @@
from enum import Enum
class LoanStatus(str, Enum):
PENDING = "pending"
ACTIVE = "active"
ACTIVE_PARTIAL = "active_partial"
START_REPAY = "start_repay"
REPAID = "repaid"
from enum import Enum
class LoanStatus(str, Enum):
PENDING = "pending"
ACTIVE = "active"
ACTIVE_PARTIAL = "active_partial"
START_REPAY = "start_repay"
REPAID = "repaid"
FAILED = "failed"
+6 -6
View File
@@ -1,7 +1,7 @@
from enum import Enum
class RepaymentScheduleStatus(str, Enum):
PARTIALLY_PAID = "partially_paid"
REPAID = "repaid"
ACTIVE = "active"
from enum import Enum
class RepaymentScheduleStatus(str, Enum):
PARTIALLY_PAID = "partially_paid"
REPAID = "repaid"
ACTIVE = "active"
OVERDUE = "overdue"
+10 -10
View File
@@ -1,10 +1,10 @@
from enum import Enum
class TransactionType(str, Enum):
ELIGIBILITY_CHECK = "eligibility_check"
CUSTOMER_CONSENT = "customer_consent"
LOAN_STATUS = "loan_status"
NOTIFICATION_CALLBACK = "notification_callback"
PROVIDE_LOAN = "provide_loan"
REPAYMENT = "repayment"
SELECT_OFFER = "select_offer"
from enum import Enum
class TransactionType(str, Enum):
ELIGIBILITY_CHECK = "eligibility_check"
CUSTOMER_CONSENT = "customer_consent"
LOAN_STATUS = "loan_status"
NOTIFICATION_CALLBACK = "notification_callback"
PROVIDE_LOAN = "provide_loan"
REPAYMENT = "repayment"
SELECT_OFFER = "select_offer"
+4 -4
View File
@@ -1,5 +1,5 @@
from flask_sqlalchemy import SQLAlchemy
from flask_mail import Mail
mail = Mail()
from flask_sqlalchemy import SQLAlchemy
from flask_mail import Mail
mail = Mail()
db = SQLAlchemy()
+62 -62
View File
@@ -1,63 +1,63 @@
import random
import string
from app.services.repayment import RepaymentService
from app.services.loan import LoanService
from app.helpers.response_helper import ResponseHelper
from app.utils.logger import logger
from app.config import settings
OVERRIDE_COLLECTION_TRANCATION_ID = settings.OVERRIDE_COLLECTION_TRANCATION_ID
class CollectLoanHelper:
@staticmethod
def _validate_repayment_and_loan(data):
repayment = RepaymentService.get_repayment_by_id(id=data['Id'])
if not repayment:
logger.info(f"Repayment id: {data['Id']}, was not found")
return None, None, ResponseHelper.error("Repayment not found")
repayment_data = repayment.to_dict()
loan = LoanService.get_loan_by_loan_id(loan_id=int(repayment_data['loanId']))
if not loan:
logger.info(f"Loan id: {repayment_data['loanId']}, was not found")
return None, None, ResponseHelper.error("Loan not found")
loan
return repayment_data, loan, None
@staticmethod
def _build_collect_loan_payload(loan_data, repayment_data, data, collectionMethod):
logger.info(f"building CollectLoan endpoint with data: {loan_data}")
debtId = str(loan_data.get('debtId', "")).strip().zfill(6)
#this can be overridden based on config
t_id = ''.join(random.choices(string.ascii_uppercase, k=22))
if OVERRIDE_COLLECTION_TRANCATION_ID == 100:
t_id = loan_data['transactionId']
return {
"transactionId": t_id,
"fbnTransactionId": loan_data['transactionId'],
"debtId": debtId,
"customerId": repayment_data['customerId'],
"accountId": loan_data['accountId'],
"productId": repayment_data['productId'],
"collectAmount": (
data['overdueLoanScheduleAmount']
if data.get('overdueLoanScheduleAmount')
is not None else loan_data.get('balance', 0)
),
"penalCharge": 0,
"channel": "USSD",
"collectionMethod": collectionMethod,
"lienAmount": 0,
"countryId": "NG",
"comment": "COLLECT LOAN"
}
@staticmethod
def chunk_list(data, chunk_size):
"""Yield successive chunk_size chunks from data."""
for i in range(0, len(data), chunk_size):
yield data[i:i + chunk_size]
import random
import string
from app.services.repayment import RepaymentService
from app.services.loan import LoanService
from app.helpers.response_helper import ResponseHelper
from app.utils.logger import logger
from app.config import settings
OVERRIDE_COLLECTION_TRANCATION_ID = settings.OVERRIDE_COLLECTION_TRANCATION_ID
class CollectLoanHelper:
@staticmethod
def _validate_repayment_and_loan(data):
repayment = RepaymentService.get_repayment_by_id(id=data['Id'])
if not repayment:
logger.info(f"Repayment id: {data['Id']}, was not found")
return None, None, ResponseHelper.error("Repayment not found")
repayment_data = repayment.to_dict()
loan = LoanService.get_loan_by_loan_id(loan_id=int(repayment_data['loanId']))
if not loan:
logger.info(f"Loan id: {repayment_data['loanId']}, was not found")
return None, None, ResponseHelper.error("Loan not found")
loan
return repayment_data, loan, None
@staticmethod
def _build_collect_loan_payload(loan_data, repayment_data, data, collectionMethod):
logger.info(f"building CollectLoan endpoint with data: {loan_data}")
debtId = str(loan_data.get('debtId', "")).strip().zfill(6)
#this can be overridden based on config
t_id = ''.join(random.choices(string.ascii_uppercase, k=22))
if OVERRIDE_COLLECTION_TRANCATION_ID == 100:
t_id = loan_data['transactionId']
return {
"transactionId": t_id,
"fbnTransactionId": loan_data['transactionId'],
"debtId": debtId,
"customerId": repayment_data['customerId'],
"accountId": loan_data['accountId'],
"productId": repayment_data['productId'],
"collectAmount": (
data['overdueLoanScheduleAmount']
if data.get('overdueLoanScheduleAmount')
is not None else loan_data.get('balance', 0)
),
"penalCharge": 0,
"channel": "USSD",
"collectionMethod": collectionMethod,
"lienAmount": 0,
"countryId": "NG",
"comment": "COLLECT LOAN"
}
@staticmethod
def chunk_list(data, chunk_size):
"""Yield successive chunk_size chunks from data."""
for i in range(0, len(data), chunk_size):
yield data[i:i + chunk_size]
+250 -250
View File
@@ -1,251 +1,251 @@
from flask import jsonify
from typing import List, Dict, Union, Optional, Any
class ResponseHelper:
"""
A helper class for building standardized JSON responses in Flask.
"""
@staticmethod
def build_response(
status: bool,
message: str,
data: Optional[Union[Dict, List, str]] = None,
status_code: int = 200,
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Build a standardized JSON response.
Args:
status (bool): Indicates whether the request was successful.
message (str): A message describing the result of the request.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
status_code (int): The HTTP status code for the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
response = {
"status": status,
"statusCode": status_code,
"message": message,
"data": data if data is not None else {},
"error": error if error is not None else {},
}
return jsonify(response), status_code
@staticmethod
def success(
data: Optional[Union[Dict, List, str]] = None,
message: str = "Successful",
status_code: int = 200,
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Return a success response.
Args:
data (Optional[Union[Dict, List, str]]): The data to return in the response.
message (str): A message describing the result of the request.
status_code (int): The HTTP status code for the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(True, message, data, status_code, error)
@staticmethod
def error(
message: str = "An error occurred",
status_code: int = 400,
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Return an error response.
Args:
message (str): A message describing the error.
status_code (int): The HTTP status code for the response.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, status_code, error)
@staticmethod
def created(
data: Optional[Union[Dict, List, str]] = None,
message: str = "Resource created successfully",
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Return a response for a created resource.
Args:
data (Optional[Union[Dict, List, str]]): The data to return in the response.
message (str): A message describing the result of the request.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(True, message, data, 201, error)
@staticmethod
def updated(
data: Optional[Union[Dict, List, str]] = None,
message: str = "Resource updated successfully",
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Return a response for an updated resource.
Args:
data (Optional[Union[Dict, List, str]]): The data to return in the response.
message (str): A message describing the result of the request.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(True, message, data, 200, error)
@staticmethod
def internal_server_error(
message: str = "Internal Server Error",
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Return a response for an internal server error.
Args:
message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, 500, error)
@staticmethod
def unauthorized(
message: str = "Unauthorized",
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Return a response for an unauthorized request.
Args:
message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, 401, error)
@staticmethod
def forbidden(
message: str = "Forbidden",
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Return a response for a forbidden request.
Args:
message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, 403, error)
@staticmethod
def not_found(
message: str = "Resource not found",
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Return a response for a not found resource.
Args:
message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, 404, error)
@staticmethod
def unprocessable_entity(
message: str = "Unprocessable entity",
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Return a response for an unprocessable entity.
Args:
message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, 422, error)
@staticmethod
def method_not_allowed(
message: str = "Method Not Allowed",
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Return a response for a method not allowed error.
Args:
message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, 405, error)
@staticmethod
def bad_request(
message: str = "Bad Request",
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Return a response for a bad request error.
Args:
message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
from flask import jsonify
from typing import List, Dict, Union, Optional, Any
class ResponseHelper:
"""
A helper class for building standardized JSON responses in Flask.
"""
@staticmethod
def build_response(
status: bool,
message: str,
data: Optional[Union[Dict, List, str]] = None,
status_code: int = 200,
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Build a standardized JSON response.
Args:
status (bool): Indicates whether the request was successful.
message (str): A message describing the result of the request.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
status_code (int): The HTTP status code for the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
response = {
"status": status,
"statusCode": status_code,
"message": message,
"data": data if data is not None else {},
"error": error if error is not None else {},
}
return jsonify(response), status_code
@staticmethod
def success(
data: Optional[Union[Dict, List, str]] = None,
message: str = "Successful",
status_code: int = 200,
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Return a success response.
Args:
data (Optional[Union[Dict, List, str]]): The data to return in the response.
message (str): A message describing the result of the request.
status_code (int): The HTTP status code for the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(True, message, data, status_code, error)
@staticmethod
def error(
message: str = "An error occurred",
status_code: int = 400,
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Return an error response.
Args:
message (str): A message describing the error.
status_code (int): The HTTP status code for the response.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, status_code, error)
@staticmethod
def created(
data: Optional[Union[Dict, List, str]] = None,
message: str = "Resource created successfully",
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Return a response for a created resource.
Args:
data (Optional[Union[Dict, List, str]]): The data to return in the response.
message (str): A message describing the result of the request.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(True, message, data, 201, error)
@staticmethod
def updated(
data: Optional[Union[Dict, List, str]] = None,
message: str = "Resource updated successfully",
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Return a response for an updated resource.
Args:
data (Optional[Union[Dict, List, str]]): The data to return in the response.
message (str): A message describing the result of the request.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(True, message, data, 200, error)
@staticmethod
def internal_server_error(
message: str = "Internal Server Error",
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Return a response for an internal server error.
Args:
message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, 500, error)
@staticmethod
def unauthorized(
message: str = "Unauthorized",
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Return a response for an unauthorized request.
Args:
message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, 401, error)
@staticmethod
def forbidden(
message: str = "Forbidden",
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Return a response for a forbidden request.
Args:
message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, 403, error)
@staticmethod
def not_found(
message: str = "Resource not found",
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Return a response for a not found resource.
Args:
message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, 404, error)
@staticmethod
def unprocessable_entity(
message: str = "Unprocessable entity",
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Return a response for an unprocessable entity.
Args:
message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, 422, error)
@staticmethod
def method_not_allowed(
message: str = "Method Not Allowed",
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Return a response for a method not allowed error.
Args:
message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, 405, error)
@staticmethod
def bad_request(
message: str = "Bad Request",
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
) -> Dict[str, Any]:
"""
Return a response for a bad request error.
Args:
message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, 400, error)
+2 -2
View File
@@ -1,3 +1,3 @@
from .kafka import KafkaIntegration
from .simbrella import SimbrellaClient
from .kafka import KafkaIntegration
from .simbrella import SimbrellaClient
from .bank_service import BankService
+24 -24
View File
@@ -1,25 +1,25 @@
import requests
from app.config import settings
from app.utils.auth import get_headers
from app.utils.logger import logger
class BankService:
BANK_CALL_BASE_URL = settings.BANK_CALL_BASE_URL
BANK_HEALTH_CHECK_ENDPOINT = settings.BANK_HEALTH_CHECK_ENDPOINT
BANK_CALL_APP_ID = settings.BANK_CALL_APP_ID
@staticmethod
def health_check():
api_url = f"{BankService.BANK_CALL_BASE_URL}{BankService.BANK_HEALTH_CHECK_ENDPOINT}"
logger.info(f"Calling Health Check endpoint: {api_url}")
try:
response = requests.get(api_url, timeout=5, headers=get_headers())
logger.info(f"Health Check response status code: {response.status_code}")
return response.json()
except Exception as e:
logger.error(f"Health Check API call failed: {str(e)}", exc_info=True)
import requests
from app.config import settings
from app.utils.auth import get_headers
from app.utils.logger import logger
class BankService:
BANK_CALL_BASE_URL = settings.BANK_CALL_BASE_URL
BANK_HEALTH_CHECK_ENDPOINT = settings.BANK_HEALTH_CHECK_ENDPOINT
BANK_CALL_APP_ID = settings.BANK_CALL_APP_ID
@staticmethod
def health_check():
api_url = f"{BankService.BANK_CALL_BASE_URL}{BankService.BANK_HEALTH_CHECK_ENDPOINT}"
logger.info(f"Calling Health Check endpoint: {api_url}")
try:
response = requests.get(api_url, timeout=5, headers=get_headers())
logger.info(f"Health Check response status code: {response.status_code}")
return response.json()
except Exception as e:
logger.error(f"Health Check API call failed: {str(e)}", exc_info=True)
raise
+156 -156
View File
@@ -1,156 +1,156 @@
from confluent_kafka import Consumer, Producer
import json
from app.utils.logger import logger
from app.config import settings
import requests
from app.integrations.simbrella import SimbrellaClient
class KafkaIntegration:
BASE_URL = settings.BANK_CALL_BASE_URL
_consumer = None
_consumer_config = {
"bootstrap.servers": settings.KAFKA_BROKER,
"group.id": "loan-service-consumer",
"auto.offset.reset": "earliest",
"enable.auto.commit": True,
}
@staticmethod
def _get_consumer():
"""Kafka consumer"""
if not KafkaIntegration._consumer:
KafkaIntegration._consumer = Consumer(KafkaIntegration._consumer_config)
logger.info(
f"Consumer connected to Kafka broker at {KafkaIntegration._consumer_config['bootstrap.servers']}"
)
return KafkaIntegration._consumer
@staticmethod
def delivery_report(err, msg):
"""Called once for each message produced"""
if err is not None:
logger.error(f"Message delivery failed: {err}")
raise RuntimeError(f"Message delivery failed: {err}")
else:
logger.debug(
f"Message delivered to {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}"
)
@staticmethod
def receive_messages(topics, timeout):
"""
Receive messages from a Kafka topic.
:param topics: The Kafka topics to subscribe to
:param timeout: Time to wait for a message (in seconds)
:return: The message value (decoded) or None if no message is received
"""
consumer = KafkaIntegration._get_consumer()
consumer.subscribe(topics)
logger.info(
f"Waiting for messages from topic {topics} with this timeout: {timeout}..."
)
message = []
try:
msg = consumer.poll(timeout=timeout)
logger.info(str(msg.value))
logger.info(
f"Received message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: "
)
if msg is None:
logger.info(f"No message received from topic {topics} within timeout")
return None
if msg.error():
logger.info(f"Consumer error: {msg.error()}")
raise RuntimeError(f"Consumer error: {msg.error()}")
# Decode and return the message value
message_value= {"value":''}
if msg.value() != "":
message_value = msg.value().decode("utf-8")
logger.info(
f"Received message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: {message_value}"
)
# Invalid Json will stop this process
try:
message = json.loads(message_value) if message_value else None
except ValueError as e:
return message # Invalid JSON JUST TURN BACK HERE
else:
pass # valid json
# Call the endpoint if provided
if message:
logger.info(
f"Received message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: {message}"
)
current_topic = msg.topic()
if current_topic=="PROCESS_PAYMENT":
KafkaIntegration._call_disbursement_service(message)
if current_topic=="LOAN_REPAYMENT":
KafkaIntegration._call_collect_loan_service(message)
logger.info(
f"Loan Repayment message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: {message}"
)
return message
except Exception as e:
logger.error(f"Error while receiving message: {e}")
raise
#return []
@staticmethod
def close_consumer():
"""Shutdown consumer"""
consumer = KafkaIntegration._get_consumer()
consumer.close()
logger.info("Kafka consumer closed")
@staticmethod
def _call_disbursement_service(message):
"""Call the disbursement service with the received message"""
logger.info(f"Calling disbursement service with message: {message}")
try:
response = SimbrellaClient.disburse_loan(message)
logger.info(
f"Successfully sent message to disbursement service: {response}"
)
# LoanService.set_disbursement_date(loan_id=loan_data['debtId'],
# customer_id=customerId) # must mark it on way out
#
except Exception as e:
logger.info(f"Failed to call disbursement service: {e}")
#raise
@staticmethod
def _call_collect_loan_service(message):
"""Call the collect loan service with the received message"""
logger.info(f"Calling collect_loan service with message: {message}")
try:
#Calling CollectLoan endpoint with data: {'transactionId': 'TRCVIC85641527829', 'customerId': 'ZX48440946', 'productId': 'AMPC', 'loanRef': 'TRCVIC85641527829USSDAMPC', 'debtId': '014231'}
response = SimbrellaClient.collect_loan_user_initiated(message)
logger.info(
f"Successfully sent message to collect_loan service: {response}"
)
except Exception as e:
logger.error(f"Failed to call collect_loan service: {e}")
# raise
from confluent_kafka import Consumer, Producer
import json
from app.utils.logger import logger
from app.config import settings
import requests
from app.integrations.simbrella import SimbrellaClient
class KafkaIntegration:
BASE_URL = settings.BANK_CALL_BASE_URL
_consumer = None
_consumer_config = {
"bootstrap.servers": settings.KAFKA_BROKER,
"group.id": "loan-service-consumer",
"auto.offset.reset": "earliest",
"enable.auto.commit": True,
}
@staticmethod
def _get_consumer():
"""Kafka consumer"""
if not KafkaIntegration._consumer:
KafkaIntegration._consumer = Consumer(KafkaIntegration._consumer_config)
logger.info(
f"Consumer connected to Kafka broker at {KafkaIntegration._consumer_config['bootstrap.servers']}"
)
return KafkaIntegration._consumer
@staticmethod
def delivery_report(err, msg):
"""Called once for each message produced"""
if err is not None:
logger.error(f"Message delivery failed: {err}")
raise RuntimeError(f"Message delivery failed: {err}")
else:
logger.debug(
f"Message delivered to {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}"
)
@staticmethod
def receive_messages(topics, timeout):
"""
Receive messages from a Kafka topic.
:param topics: The Kafka topics to subscribe to
:param timeout: Time to wait for a message (in seconds)
:return: The message value (decoded) or None if no message is received
"""
consumer = KafkaIntegration._get_consumer()
consumer.subscribe(topics)
logger.info(
f"Waiting for messages from topic {topics} with this timeout: {timeout}..."
)
message = []
try:
msg = consumer.poll(timeout=timeout)
logger.info(str(msg.value))
logger.info(
f"Received message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: "
)
if msg is None:
logger.info(f"No message received from topic {topics} within timeout")
return None
if msg.error():
logger.info(f"Consumer error: {msg.error()}")
raise RuntimeError(f"Consumer error: {msg.error()}")
# Decode and return the message value
message_value= {"value":''}
if msg.value() != "":
message_value = msg.value().decode("utf-8")
logger.info(
f"Received message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: {message_value}"
)
# Invalid Json will stop this process
try:
message = json.loads(message_value) if message_value else None
except ValueError as e:
return message # Invalid JSON JUST TURN BACK HERE
else:
pass # valid json
# Call the endpoint if provided
if message:
logger.info(
f"Received message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: {message}"
)
current_topic = msg.topic()
if current_topic=="PROCESS_PAYMENT":
KafkaIntegration._call_disbursement_service(message)
if current_topic=="LOAN_REPAYMENT":
KafkaIntegration._call_collect_loan_service(message)
logger.info(
f"Loan Repayment message from {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}: {message}"
)
return message
except Exception as e:
logger.error(f"Error while receiving message: {e}")
raise
#return []
@staticmethod
def close_consumer():
"""Shutdown consumer"""
consumer = KafkaIntegration._get_consumer()
consumer.close()
logger.info("Kafka consumer closed")
@staticmethod
def _call_disbursement_service(message):
"""Call the disbursement service with the received message"""
logger.info(f"Calling disbursement service with message: {message}")
try:
response = SimbrellaClient.disburse_loan(message)
logger.info(
f"Successfully sent message to disbursement service: {response}"
)
# LoanService.set_disbursement_date(loan_id=loan_data['debtId'],
# customer_id=customerId) # must mark it on way out
#
except Exception as e:
logger.info(f"Failed to call disbursement service: {e}")
#raise
@staticmethod
def _call_collect_loan_service(message):
"""Call the collect loan service with the received message"""
logger.info(f"Calling collect_loan service with message: {message}")
try:
#Calling CollectLoan endpoint with data: {'transactionId': 'TRCVIC85641527829', 'customerId': 'ZX48440946', 'productId': 'AMPC', 'loanRef': 'TRCVIC85641527829USSDAMPC', 'debtId': '014231'}
response = SimbrellaClient.collect_loan_user_initiated(message)
logger.info(
f"Successfully sent message to collect_loan service: {response}"
)
except Exception as e:
logger.error(f"Failed to call collect_loan service: {e}")
# raise
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -1,10 +1,10 @@
from .transactions import Transaction
from .repayment import Repayment
from .loan import Loan
from .loan_charge import LoanCharge
from .customer import Customer
from .account import Account
from .repayments_data import RepaymentsData
from .salary import Salary
from .transactions import Transaction
from .repayment import Repayment
from .loan import Loan
from .loan_charge import LoanCharge
from .customer import Customer
from .account import Account
from .repayments_data import RepaymentsData
from .salary import Salary
__all__ = ['Transaction', 'Repayment', 'Loan', 'LoanCharge', 'Customer', 'Account', 'RepaymentsData','Salary']
+25 -25
View File
@@ -1,25 +1,25 @@
from datetime import datetime, timezone
from sqlalchemy.orm import relationship
from app.extensions import db
class Account(db.Model):
__tablename__ = 'accounts'
id = db.Column(db.String(50), primary_key=True)
customer_id = db.Column(db.String(50), nullable=False)
account_type = db.Column(db.String(50))
status = db.Column(db.String(20), default='active')
lien_amount = db.Column(db.Float, default=0.0)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
customer = relationship(
"Customer",
primaryjoin="Customer.id == Account.customer_id",
foreign_keys=[customer_id],
back_populates="accounts",
)
def __repr__(self):
return f'<Account {self.id}>'
from datetime import datetime, timezone
from sqlalchemy.orm import relationship
from app.extensions import db
class Account(db.Model):
__tablename__ = 'accounts'
id = db.Column(db.String(50), primary_key=True)
customer_id = db.Column(db.String(50), nullable=False)
account_type = db.Column(db.String(50))
status = db.Column(db.String(20), default='active')
lien_amount = db.Column(db.Float, default=0.0)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
customer = relationship(
"Customer",
primaryjoin="Customer.id == Account.customer_id",
foreign_keys=[customer_id],
back_populates="accounts",
)
def __repr__(self):
return f'<Account {self.id}>'
+41 -41
View File
@@ -1,41 +1,41 @@
from datetime import datetime, timezone
from sqlalchemy.orm import relationship
from app.extensions import db
class Customer(db.Model):
__tablename__ = 'customers'
id = db.Column(db.String(50), primary_key=True)
msisdn = db.Column(db.String(20), unique=True, nullable=False)
country_code = db.Column(db.String(3), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
accounts = relationship(
"Account",
primaryjoin="Customer.id == Account.customer_id",
foreign_keys="Account.customer_id",
back_populates="customer",
)
loans = relationship(
"Loan",
primaryjoin="Customer.id == Loan.customer_id",
foreign_keys="Loan.customer_id",
back_populates="customer",
)
@classmethod
def get_customer(cls, customer_id):
"""
Get customer by ID.
"""
customer = cls.query.filter_by(id=customer_id).first()
if not customer:
raise ValueError(f"Customer does not exist")
return customer
def __repr__(self):
return f'<Customer {self.id}>'
from datetime import datetime, timezone
from sqlalchemy.orm import relationship
from app.extensions import db
class Customer(db.Model):
__tablename__ = 'customers'
id = db.Column(db.String(50), primary_key=True)
msisdn = db.Column(db.String(20), unique=True, nullable=False)
country_code = db.Column(db.String(3), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
accounts = relationship(
"Account",
primaryjoin="Customer.id == Account.customer_id",
foreign_keys="Account.customer_id",
back_populates="customer",
)
loans = relationship(
"Loan",
primaryjoin="Customer.id == Loan.customer_id",
foreign_keys="Loan.customer_id",
back_populates="customer",
)
@classmethod
def get_customer(cls, customer_id):
"""
Get customer by ID.
"""
customer = cls.query.filter_by(id=customer_id).first()
if not customer:
raise ValueError(f"Customer does not exist")
return customer
def __repr__(self):
return f'<Customer {self.id}>'
+451 -415
View File
@@ -1,415 +1,451 @@
from datetime import datetime, timezone
from sqlalchemy.orm import relationship
from dateutil.relativedelta import relativedelta
from datetime import timedelta
import logging
from sqlalchemy import and_, or_, not_
from sqlalchemy.sql import func
from app.utils.logger import logger
from app.extensions import db
from decimal import Decimal, ROUND_HALF_UP
from datetime import datetime, timezone
class Loan(db.Model):
__tablename__ = "loans"
id = db.Column(
db.Integer,
primary_key=True,
autoincrement=True,
)
customer_id = db.Column(db.String(50), nullable=False)
transaction_id = db.Column(db.String(50), nullable=True)
original_transaction = db.Column(db.String(50), nullable=True)
account_id = db.Column(db.String(50), nullable=False)
offer_id = db.Column(db.String(20), nullable=False)
product_id = db.Column(db.String(20), nullable=True)
collection_type = db.Column(db.String(20), nullable=True)
current_loan_amount = db.Column(db.Float, nullable=True)
initial_loan_amount = db.Column(db.Float, nullable=False)
default_penalty_fee = db.Column(db.Float, default=0)
continuous_fee = db.Column(db.Float, default=0)
upfront_fee = db.Column(db.Float, nullable=True, default=0.0)
repayment_amount = db.Column(db.Float, nullable=True, default=0.0)
balance = db.Column(db.Float, nullable=True, default=0.0)
installment_amount = db.Column(db.Float, nullable=True, default=0.0)
status = db.Column(db.String(20), default='pending')
tenor = db.Column(db.Integer, nullable=True)
due_date = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
eligible_amount = db.Column(db.Float, nullable=True, default=0.0)
disburse_date = db.Column(db.DateTime, nullable=True)
disburse_verify = db.Column(db.DateTime, nullable=True)
disburse_result = db.Column(db.String(10), nullable=True)
disburse_description = db.Column(db.String(100), nullable=True)
verify_result = db.Column(db.String(10), nullable=True)
verify_description = db.Column(db.String(100), nullable=True)
reference = db.Column(db.String(50), nullable=True)
total_penal_charge = db.Column(db.Float, default=0.0)
last_penal_date = db.Column(db.DateTime, nullable=True)
customer = relationship(
"Customer",
primaryjoin="Customer.id == Loan.customer_id",
foreign_keys=[customer_id],
back_populates="loans",
)
loan_charges = relationship(
"LoanCharge",
primaryjoin="Loan.id == LoanCharge.loan_id",
foreign_keys="LoanCharge.loan_id",
back_populates="loan",
)
def __repr__(self):
return f"<Loan {self.id}>"
def to_dict(self):
"""
Convert the Loan object to a dictionary format for JSON serialization.
"""
return {
'debtId': self.id,
"customerId": self.customer_id,
'initialLoanAmount': self.initial_loan_amount,
'currentLoanAmount': self.current_loan_amount,
'defaultPenaltyFee': self.default_penalty_fee,
'continuousFee': self.continuous_fee,
'collectionType': self.collection_type,
'repaymentAmount':self.repayment_amount,
'status': self.status,
'productId': self.product_id,
'disburseResult': self.disburse_result,
'disburseDescription': self.disburse_description,
'verifyResult': self.verify_result,
'verifyDescription': self.verify_description,
'transactionId': self.transaction_id,
'accountId':self.account_id,
'dueDate': self.due_date.isoformat() if self.due_date else None,
'loanDate': self.created_at.isoformat() if self.created_at else None,
'disburseDate': self.disburse_date.isoformat() if self.disburse_date else None,
'disburseVerify': self.disburse_verify.isoformat() if self.disburse_verify else None,
'reference': self.reference,
'balance': self.balance,
'tenor': self.tenor,
'totalPenalCharge': self.total_penal_charge,
'lastPenalDate': self.last_penal_date
}
@classmethod
def get_loan_by_transaction_id(cls, transaction_id):
return cls.query.filter_by(transaction_id=transaction_id).first()
@classmethod
def get_loan_by_loan_id(cls, loan_id):
return cls.query.filter_by(id=loan_id).first()
@classmethod
def set_disbursement_date(cls, loan_id, customer_id):
"""
Update the disburse date of the loan with the given loan_id.
"""
# Retrieve loan
loan = cls.query.get(loan_id)
if not loan:
raise ValueError(f"Loan with ID {loan_id} does not exist.")
# Check if customer_id matches
if loan.customer_id != customer_id:
raise ValueError(f"Customer ID {customer_id} does not match the loan's customer ID.")
current_time = datetime.now()
logger.info(f"What is now ======= ==== ==> : {current_time}")
# Update loan disburse_date
loan.disburse_date = current_time
# Commit changes to database
try:
logger.info(f"Updating disburse date for loan ID {loan_id} to {current_time}")
db.session.commit()
except Exception as e:
db.session.rollback()
logger.error(f"Failed to update disburse date: {e}")
raise
@classmethod
def set_disburse_verify_date(cls, loan_id, customer_id):
"""
Update the disburse verify date of the loan with the given loan_id.
"""
# Retrieve loan
loan = cls.query.get(loan_id)
if not loan:
raise ValueError(f"Loan with ID {loan_id} does not exist.")
# Check if customer_id matches
if loan.customer_id != customer_id:
raise ValueError(f"Customer ID {customer_id} does not match the loan's customer ID.")
current_time = datetime.now()
logger.info(f"What is now ======= ==== ==> : {current_time}")
# Update loan verify_date
loan.disburse_verify = current_time
# Commit changes to database
try:
logger.info(f"Updating disburse verify date for loan ID {loan_id} to {current_time}")
db.session.commit()
except Exception as e:
db.session.rollback()
logger.error(f"Failed to update disburse verify date: {e}")
raise
@classmethod
def set_disbursement_message(cls, loan_id, description):
"""
Update the disburse result and description of the loan with the given loan_id.
"""
# Retrieve loan
loan = cls.query.get(loan_id)
if not loan:
raise ValueError(f"Loan with ID {loan_id} does not exist.")
# Update disburse description only
loan.disburse_description = description
# Commit changes to database
try:
logger.info(f"Updating disburse result for loan ID {loan_id} with description {description}")
db.session.commit()
except Exception as e:
db.session.rollback()
logger.error(f"Failed to update disbursement result: {e}")
raise
@classmethod
def set_disbursement_result(cls, loan_id, result, description):
"""
Update the disburse result and description of the loan with the given loan_id.
"""
# Retrieve loan
loan = cls.query.get(loan_id)
if not loan:
raise ValueError(f"Loan with ID {loan_id} does not exist.")
# Update disburse result and description
loan.disburse_result = result
loan.disburse_description = description
# Commit changes to database
try:
logger.info(f"Updating disburse result for loan ID {loan_id} to {result} with description {description}")
db.session.commit()
except Exception as e:
db.session.rollback()
logger.error(f"Failed to update disbursement result: {e}")
raise
@classmethod
def set_disburse_verify_result(cls, loan_id, result, description):
"""
Update the verify result and description of the loan with the given loan_id.
"""
# Retrieve loan
loan = cls.query.get(loan_id)
if not loan:
raise ValueError(f"Loan with ID {loan_id} does not exist.")
# Update disburse result and description
loan.verify_result = result
loan.verify_description = description
# Commit changes to database
try:
logger.info(f"Updating verify result for loan ID {loan_id} to {result} with description {description}")
db.session.commit()
except Exception as e:
db.session.rollback()
logger.error(f"Failed to update verify result: {e}")
raise
@classmethod
def get_latest_loan_without_disburse_date(cls):
"""
Get the latest loan without a disbursement date.
"""
logger.info("Fetching latest loan without disburse date")
try:
return cls.query.filter(
cls.disburse_date.is_(None)
).order_by(cls.created_at.desc()).first()
except Exception as e:
logger.error(f"Error fetching latest loan without disburse date: {e}")
raise
@classmethod
def get_latest_loan_with_disburse_date(cls):
"""
Get the latest loan with a disbursement date and no verification date.
"""
return cls.query.filter(
cls.disburse_date.isnot(None),
cls.disburse_verify.is_(None)
).order_by(cls.created_at.desc()).first()
@classmethod
def get_customer_loans(cls, customer_id):
"""
Get customer's active loans and sum by customer_id.
"""
customer_loans = cls.query.filter_by( customer_id = customer_id).all()
if not customer_loans:
raise ValueError(f"Customer with Id {customer_id} does not have any loan.")
total_amount = (
cls.query.with_entities(func.coalesce(func.sum(cls.balance), 0.0))
.filter_by(customer_id=customer_id)
.scalar()
)
logger.info(f"Found {len(customer_loans)} loans for customer ID: {customer_id} with total amount: {total_amount}")
return customer_loans, total_amount
@classmethod
def get_customer_active_loans(cls, customer_id):
"""
Get customer's active loans and sum by customer_id.
"""
customer_loans = cls.query.filter(
cls.customer_id == customer_id,
cls.status != 'repaid'
).all()
if not customer_loans:
raise ValueError(f"Customer with Id {customer_id} does not have any active loan.")
total_amount = (
cls.query
.with_entities(func.coalesce(func.sum(cls.balance), 0.0))
.filter(
cls.customer_id == customer_id,
cls.status != 'repaid'
)
.scalar()
)
logger.info(f"Found {len(customer_loans)} active loans for customer ID: {customer_id} with total amount: {total_amount}")
return customer_loans, total_amount
@classmethod
def update_status(cls, loan_id, status):
"""
Update the status of the loan record with the given loan_id.
"""
try:
# Retrieve loan record
loan = cls.query.get(loan_id)
if not loan:
raise ValueError(f"Loan with ID {loan_id} does not exist.")
if loan.status == status:
return loan.to_dict() # Still return the current state if no change
# Update status and timestamp
loan.status = status
loan.updated_at = datetime.now(timezone.utc)
db.session.commit()
logger.info("Loan status updated and committed.")
return loan.to_dict()
except Exception as e:
db.session.rollback()
logger.error(f"Error updating loan status: {e}")
raise Exception(f"Error updating loan status: {str(e)}")
@classmethod
def update_loan_balance(cls, loan_id, amount_collected):
"""
Update the balance of a loan after successful repayment.
"""
try:
# Fetch the loan record
loan = cls.query.get(loan_id)
if not loan:
raise ValueError(f"Loan with ID {loan_id} does not exist.")
# Convert to Decimal and round to 2 decimal places
amount_collected = Decimal(str(amount_collected)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
balance = Decimal(str(loan.balance or 0)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
# Ensure valid repayment amount
if amount_collected <= Decimal("0.00"):
logger.info(f"Repayment amount is less than or equal to 0: {amount_collected}. Must be greater than 0.00")
if balance <= Decimal("0.00"):
raise ValueError("There is no balance for this loan.")
if amount_collected > balance:
# allow tiny rounding diff
if abs(amount_collected - balance) <= Decimal("0.01"):
amount_collected = balance
else:
raise ValueError("Repayment amount exceeds current loan balance.")
# Deduct the amount from the current balance
new_balance = balance - amount_collected
loan.balance = float(new_balance)
loan.updated_at = datetime.now(timezone.utc)
db.session.commit()
logger.info(f"Loan balance updated for loan ID {loan_id}. New balance: {loan.balance}")
return loan.to_dict()
except Exception as e:
db.session.rollback()
logger.error(f"Error updating loan balance: {e}")
raise Exception(f"Error updating loan balance: {str(e)}")
@classmethod
def get_overdue_loans(cls):
"""
Get all overdue loans.
"""
try:
overdue_loans = cls.query.filter(
cls.due_date < datetime.now(timezone.utc),
cls.status != 'repaid'
).all()
if not overdue_loans:
logger.info("No overdue loans found.")
return []
logger.info(f"Found {len(overdue_loans)} overdue loans.")
return overdue_loans
except Exception as e:
logger.error(f"Error fetching overdue loans: {e}")
return []
@classmethod
def apply_penal_to_loan(cls, loan_id, penal_amount):
loan = cls.query.get(loan_id)
if not loan:
raise ValueError("Loan not found")
penal_amount = Decimal(str(penal_amount))
loan.total_penal_charge = Decimal(str(loan.total_penal_charge or 0)) + penal_amount
loan.last_penal_date = datetime.now(timezone.utc)
db.session.commit()
from datetime import datetime, timezone
from sqlalchemy.orm import relationship
from dateutil.relativedelta import relativedelta
from datetime import timedelta
import logging
from sqlalchemy import and_, or_, not_
from sqlalchemy.sql import func
from app.utils.logger import logger
from app.extensions import db
from decimal import Decimal, ROUND_HALF_UP
from datetime import datetime, timezone
from app.config import settings
class Loan(db.Model):
__tablename__ = "loans"
id = db.Column(
db.Integer,
primary_key=True,
autoincrement=True,
)
customer_id = db.Column(db.String(50), nullable=False)
transaction_id = db.Column(db.String(50), nullable=True)
original_transaction = db.Column(db.String(50), nullable=True)
account_id = db.Column(db.String(50), nullable=False)
offer_id = db.Column(db.String(20), nullable=False)
product_id = db.Column(db.String(20), nullable=True)
collection_type = db.Column(db.String(20), nullable=True)
current_loan_amount = db.Column(db.Float, nullable=True)
initial_loan_amount = db.Column(db.Float, nullable=False)
default_penalty_fee = db.Column(db.Float, default=0)
continuous_fee = db.Column(db.Float, default=0)
upfront_fee = db.Column(db.Float, nullable=True, default=0.0)
repayment_amount = db.Column(db.Float, nullable=True, default=0.0)
balance = db.Column(db.Float, nullable=True, default=0.0)
installment_amount = db.Column(db.Float, nullable=True, default=0.0)
status = db.Column(db.String(20), default='pending')
tenor = db.Column(db.Integer, nullable=True)
due_date = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
eligible_amount = db.Column(db.Float, nullable=True, default=0.0)
disburse_date = db.Column(db.DateTime, nullable=True)
disburse_verify = db.Column(db.DateTime, nullable=True)
disburse_result = db.Column(db.String(10), nullable=True)
disburse_description = db.Column(db.String(100), nullable=True)
verify_result = db.Column(db.String(10), nullable=True)
verify_description = db.Column(db.String(100), nullable=True)
reference = db.Column(db.String(50), nullable=True)
total_penal_charge = db.Column(db.Float, default=0.0)
last_penal_date = db.Column(db.DateTime, nullable=True)
customer = relationship(
"Customer",
primaryjoin="Customer.id == Loan.customer_id",
foreign_keys=[customer_id],
back_populates="loans",
)
loan_charges = relationship(
"LoanCharge",
primaryjoin="Loan.id == LoanCharge.loan_id",
foreign_keys="LoanCharge.loan_id",
back_populates="loan",
)
def __repr__(self):
return f"<Loan {self.id}>"
def to_dict(self):
"""
Convert the Loan object to a dictionary format for JSON serialization.
"""
return {
'debtId': self.id,
"customerId": self.customer_id,
'initialLoanAmount': self.initial_loan_amount,
'currentLoanAmount': self.current_loan_amount,
'defaultPenaltyFee': self.default_penalty_fee,
'continuousFee': self.continuous_fee,
'collectionType': self.collection_type,
'repaymentAmount':self.repayment_amount,
'status': self.status,
'productId': self.product_id,
'disburseResult': self.disburse_result,
'disburseDescription': self.disburse_description,
'verifyResult': self.verify_result,
'verifyDescription': self.verify_description,
'transactionId': self.transaction_id,
'accountId':self.account_id,
'dueDate': self.due_date.isoformat() if self.due_date else None,
'loanDate': self.created_at.isoformat() if self.created_at else None,
'disburseDate': self.disburse_date.isoformat() if self.disburse_date else None,
'disburseVerify': self.disburse_verify.isoformat() if self.disburse_verify else None,
'reference': self.reference,
'balance': self.balance,
'tenor': self.tenor,
'totalPenalCharge': self.total_penal_charge,
'lastPenalDate': self.last_penal_date
}
@classmethod
def get_loan_by_transaction_id(cls, transaction_id):
return cls.query.filter_by(transaction_id=transaction_id).first()
#update loan status
@classmethod
def update_status(cls, loan_id, status):
loan = cls.query.get(loan_id)
if loan:
loan.status = status
db.session.commit()
return loan.to_dict()
else:
raise ValueError(f"Loan with ID {loan_id} not found.")
@classmethod
def get_loan_by_loan_id(cls, loan_id):
return cls.query.filter_by(id=loan_id).first()
@classmethod
def set_disbursement_date(cls, loan_id, customer_id):
"""
Update the disburse date of the loan with the given loan_id.
"""
# Retrieve loan
loan = cls.query.get(loan_id)
if not loan:
raise ValueError(f"Loan with ID {loan_id} does not exist.")
# Check if customer_id matches
if loan.customer_id != customer_id:
raise ValueError(f"Customer ID {customer_id} does not match the loan's customer ID.")
current_time = datetime.now()
logger.info(f"What is now ======= ==== ==> : {current_time}")
# Update loan disburse_date
loan.disburse_date = current_time
# Commit changes to database
try:
logger.info(f"Updating disburse date for loan ID {loan_id} to {current_time}")
db.session.commit()
except Exception as e:
db.session.rollback()
logger.error(f"Failed to update disburse date: {e}")
raise
@classmethod
def set_disburse_verify_date(cls, loan_id, customer_id):
"""
Update the disburse verify date of the loan with the given loan_id.
"""
# Retrieve loan
loan = cls.query.get(loan_id)
if not loan:
raise ValueError(f"Loan with ID {loan_id} does not exist.")
# Check if customer_id matches
if loan.customer_id != customer_id:
raise ValueError(f"Customer ID {customer_id} does not match the loan's customer ID.")
current_time = datetime.now()
logger.info(f"What is now ======= ==== ==> : {current_time}")
# Update loan verify_date
loan.disburse_verify = current_time
# Commit changes to database
try:
logger.info(f"Updating disburse verify date for loan ID {loan_id} to {current_time}")
db.session.commit()
except Exception as e:
db.session.rollback()
logger.error(f"Failed to update disburse verify date: {e}")
raise
@classmethod
def set_disbursement_message(cls, loan_id, description):
"""
Update the disburse result and description of the loan with the given loan_id.
"""
# Retrieve loan
loan = cls.query.get(loan_id)
if not loan:
raise ValueError(f"Loan with ID {loan_id} does not exist.")
# Update disburse description only
loan.disburse_description = description
# Commit changes to database
try:
logger.info(f"Updating disburse result for loan ID {loan_id} with description {description}")
db.session.commit()
except Exception as e:
db.session.rollback()
logger.error(f"Failed to update disbursement result: {e}")
raise
@classmethod
def set_disbursement_result(cls, loan_id, result, description):
"""
Update the disburse result and description of the loan with the given loan_id.
"""
# Retrieve loan
loan = cls.query.get(loan_id)
if not loan:
raise ValueError(f"Loan with ID {loan_id} does not exist.")
# Update disburse result and description
loan.disburse_result = result
loan.disburse_description = description
# Commit changes to database
try:
logger.info(f"Updating disburse result for loan ID {loan_id} to {result} with description {description}")
db.session.commit()
except Exception as e:
db.session.rollback()
logger.error(f"Failed to update disbursement result: {e}")
raise
@classmethod
def set_disburse_verify_result(cls, loan_id, result, description):
"""
Update the verify result and description of the loan with the given loan_id.
"""
# Retrieve loan
loan = cls.query.get(loan_id)
if not loan:
raise ValueError(f"Loan with ID {loan_id} does not exist.")
# Update disburse result and description
loan.verify_result = result
loan.verify_description = description
# Commit changes to database
try:
logger.info(f"Updating verify result for loan ID {loan_id} to {result} with description {description}")
db.session.commit()
except Exception as e:
db.session.rollback()
logger.error(f"Failed to update verify result: {e}")
raise
@classmethod
def get_latest_loan_without_disburse_date(cls):
"""
Get the latest loan without a disbursement date.
"""
logger.info("Fetching latest loan without disburse date")
try:
return cls.query.filter(
cls.disburse_date.is_(None)
).order_by(cls.created_at.desc()).first()
except Exception as e:
logger.error(f"Error fetching latest loan without disburse date: {e}")
raise
@classmethod
def get_failed_disbursements(cls):
"""
Get all loans with failed disbursement.
"""
try:
last_time_interval = settings.FAILED_RETRY_TIME_INTERVAL_SECONDS or 0
failed_loans = cls.query.filter(
cls.disburse_date.is_(None),
cls.created_at >= datetime.utcnow() - timedelta(seconds=last_time_interval)
).all()
if not failed_loans:
logger.info("No failed disbursements found.")
return []
logger.info(f"Found {len(failed_loans)} failed disbursements.")
return failed_loans
except Exception as e:
logger.error(f"Error fetching failed disbursements: {e}")
return []
@classmethod
def get_latest_loan_with_disburse_date(cls):
"""
Get the latest loan with a disbursement date and no verification date.
"""
return cls.query.filter(
cls.disburse_date.isnot(None),
cls.disburse_verify.is_(None)
).order_by(cls.created_at.desc()).first()
@classmethod
def get_customer_loans(cls, customer_id):
"""
Get customer's active loans and sum by customer_id.
"""
customer_loans = cls.query.filter_by( customer_id = customer_id).all()
if not customer_loans:
raise ValueError(f"Customer with Id {customer_id} does not have any loan.")
total_amount = (
cls.query.with_entities(func.coalesce(func.sum(cls.balance), 0.0))
.filter_by(customer_id=customer_id)
.scalar()
)
logger.info(f"Found {len(customer_loans)} loans for customer ID: {customer_id} with total amount: {total_amount}")
return customer_loans, total_amount
@classmethod
def get_customer_active_loans(cls, customer_id):
"""
Get customer's active loans and sum by customer_id.
"""
customer_loans = cls.query.filter(
cls.customer_id == customer_id,
cls.status != 'repaid'
).all()
if not customer_loans:
raise ValueError(f"Customer with Id {customer_id} does not have any active loan.")
total_amount = (
cls.query
.with_entities(func.coalesce(func.sum(cls.balance), 0.0))
.filter(
cls.customer_id == customer_id,
cls.status != 'repaid'
)
.scalar()
)
logger.info(f"Found {len(customer_loans)} active loans for customer ID: {customer_id} with total amount: {total_amount}")
return customer_loans, total_amount
@classmethod
def update_status(cls, loan_id, status):
"""
Update the status of the loan record with the given loan_id.
"""
try:
# Retrieve loan record
loan = cls.query.get(loan_id)
if not loan:
raise ValueError(f"Loan with ID {loan_id} does not exist.")
if loan.status == status:
return loan.to_dict() # Still return the current state if no change
# Update status and timestamp
loan.status = status
loan.updated_at = datetime.now(timezone.utc)
db.session.commit()
logger.info("Loan status updated and committed.")
return loan.to_dict()
except Exception as e:
db.session.rollback()
logger.error(f"Error updating loan status: {e}")
raise Exception(f"Error updating loan status: {str(e)}")
@classmethod
def update_loan_balance(cls, loan_id, amount_collected):
"""
Update the balance of a loan after successful repayment.
"""
try:
# Fetch the loan record
loan = cls.query.get(loan_id)
if not loan:
raise ValueError(f"Loan with ID {loan_id} does not exist.")
# Convert to Decimal and round to 2 decimal places
amount_collected = Decimal(str(amount_collected)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
balance = Decimal(str(loan.balance or 0)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
# Ensure valid repayment amount
if amount_collected <= Decimal("0.00"):
logger.info(f"Repayment amount is less than or equal to 0: {amount_collected}. Must be greater than 0.00")
if balance <= Decimal("0.00"):
raise ValueError("There is no balance for this loan.")
if amount_collected > balance:
# allow tiny rounding diff
if abs(amount_collected - balance) <= Decimal("0.01"):
amount_collected = balance
else:
raise ValueError("Repayment amount exceeds current loan balance.")
# Deduct the amount from the current balance
new_balance = balance - amount_collected
loan.balance = float(new_balance)
loan.updated_at = datetime.now(timezone.utc)
db.session.commit()
logger.info(f"Loan balance updated for loan ID {loan_id}. New balance: {loan.balance}")
return loan.to_dict()
except Exception as e:
db.session.rollback()
logger.error(f"Error updating loan balance: {e}")
raise Exception(f"Error updating loan balance: {str(e)}")
@classmethod
def get_overdue_loans(cls):
"""
Get all overdue loans.
"""
try:
overdue_loans = cls.query.filter(
cls.due_date < datetime.now(timezone.utc),
cls.status != 'repaid'
).all()
if not overdue_loans:
logger.info("No overdue loans found.")
return []
logger.info(f"Found {len(overdue_loans)} overdue loans.")
return overdue_loans
except Exception as e:
logger.error(f"Error fetching overdue loans: {e}")
return []
@classmethod
def apply_penal_to_loan(cls, loan_id, penal_amount):
loan = cls.query.get(loan_id)
if not loan:
raise ValueError("Loan not found")
penal_amount = Decimal(str(penal_amount))
loan.total_penal_charge = Decimal(str(loan.total_penal_charge or 0)) + penal_amount
loan.last_penal_date = datetime.now(timezone.utc)
db.session.commit()
+126 -126
View File
@@ -1,127 +1,127 @@
from datetime import datetime, timezone, timedelta
from os.path import devnull
from sqlalchemy.exc import IntegrityError
from app.extensions import db
from sqlalchemy.orm import relationship
class LoanCharge(db.Model):
__tablename__ = 'loan_charges'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
loan_id = db.Column(db.Integer, nullable=False)
transaction_id = db.Column(db.String(50), nullable=True)
code = db.Column(db.String(50), nullable=False)
amount = db.Column(db.Float, default=0.0)
percent = db.Column(db.Float, default=0.0)
description = db.Column(db.Text, nullable=True)
due = db.Column(db.Integer, nullable=False)
due_date = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
loan = relationship(
"Loan",
primaryjoin="LoanCharge.loan_id == Loan.id",
foreign_keys=[loan_id],
back_populates="loan_charges",
)
def __repr__(self):
return f"<LoanCharge {self.id} - Loan {self.loan_id} - {self.code}>"
def to_dict(self):
"""
Convert the Loan charge object to a dictionary format for JSON serialization.
"""
return {
'id': self.id,
'loanId': self.loan_id,
'transactionId': self.transaction_id,
'code': self.code,
'amount': self.amount,
'percent': self.percent,
'description': self.description,
'due': self.due
}
#get last penal
@classmethod
def get_last_penal_no(cls, loan_id):
"""
Returns the last penal number created for a loan.
Example:
PENAL1 -> returns 1
PENAL3 -> returns 3
If none exists, returns 0.
"""
last_penal = (
cls.query
.filter(cls.loan_id == loan_id)
.filter(cls.code.like("PENAL%"))
.order_by(cls.id.desc())
.first()
)
if not last_penal:
return 0
try:
return int(last_penal.code.replace("PENAL", ""))
except ValueError:
return 0
@classmethod
def get_penal_charges_by_loan_id(cls, loan_id):
"""
Returns all penal charges for a specific loan.
"""
return cls.query.filter(
cls.loan_id == loan_id,
cls.code.like("PENAL%")
).all()
@classmethod
def get_loan_charge_by_debt_id(cls, debt_id):
return cls.query.filter_by(loan_id=debt_id)
#create penal charge
@classmethod
def create_penal_charges_for_loan(cls, loan_id, transaction_id, percent, penal_no, schedule_number, penal_amount=0.0):
"""
Create a penal charge for a given loan and schedule.
"""
if loan_id is None:
raise ValueError("loan_id cannot be None")
code = f"PENAL{penal_no:02d}-SCHEDULE{schedule_number:02d}"
# Check if this penal charge already exists
existing = cls.query.filter_by(
loan_id=loan_id,
code=code
).first()
if existing:
return existing
now = datetime.now(timezone.utc)
penal_charge = cls(
loan_id=loan_id,
transaction_id=transaction_id,
code=code,
amount=penal_amount,
percent=percent,
description=f"Penal Charge {penal_no} for loan {loan_id} schedule {schedule_number}",
due=True,
due_date=now
)
try:
db.session.add(penal_charge)
db.session.commit()
except IntegrityError as err:
db.session.rollback()
raise ValueError(f"Database integrity error: {err}")
from datetime import datetime, timezone, timedelta
from os.path import devnull
from sqlalchemy.exc import IntegrityError
from app.extensions import db
from sqlalchemy.orm import relationship
class LoanCharge(db.Model):
__tablename__ = 'loan_charges'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
loan_id = db.Column(db.Integer, nullable=False)
transaction_id = db.Column(db.String(50), nullable=True)
code = db.Column(db.String(50), nullable=False)
amount = db.Column(db.Float, default=0.0)
percent = db.Column(db.Float, default=0.0)
description = db.Column(db.Text, nullable=True)
due = db.Column(db.Integer, nullable=False)
due_date = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
loan = relationship(
"Loan",
primaryjoin="LoanCharge.loan_id == Loan.id",
foreign_keys=[loan_id],
back_populates="loan_charges",
)
def __repr__(self):
return f"<LoanCharge {self.id} - Loan {self.loan_id} - {self.code}>"
def to_dict(self):
"""
Convert the Loan charge object to a dictionary format for JSON serialization.
"""
return {
'id': self.id,
'loanId': self.loan_id,
'transactionId': self.transaction_id,
'code': self.code,
'amount': self.amount,
'percent': self.percent,
'description': self.description,
'due': self.due
}
#get last penal
@classmethod
def get_last_penal_no(cls, loan_id):
"""
Returns the last penal number created for a loan.
Example:
PENAL1 -> returns 1
PENAL3 -> returns 3
If none exists, returns 0.
"""
last_penal = (
cls.query
.filter(cls.loan_id == loan_id)
.filter(cls.code.like("PENAL%"))
.order_by(cls.id.desc())
.first()
)
if not last_penal:
return 0
try:
return int(last_penal.code.replace("PENAL", ""))
except ValueError:
return 0
@classmethod
def get_penal_charges_by_loan_id(cls, loan_id):
"""
Returns all penal charges for a specific loan.
"""
return cls.query.filter(
cls.loan_id == loan_id,
cls.code.like("PENAL%")
).all()
@classmethod
def get_loan_charge_by_debt_id(cls, debt_id):
return cls.query.filter_by(loan_id=debt_id)
#create penal charge
@classmethod
def create_penal_charges_for_loan(cls, loan_id, transaction_id, percent, penal_no, schedule_number, penal_amount=0.0):
"""
Create a penal charge for a given loan and schedule.
"""
if loan_id is None:
raise ValueError("loan_id cannot be None")
code = f"PENAL{penal_no:02d}-SCHEDULE{schedule_number:02d}"
# Check if this penal charge already exists
existing = cls.query.filter_by(
loan_id=loan_id,
code=code
).first()
if existing:
return existing
now = datetime.now(timezone.utc)
penal_charge = cls(
loan_id=loan_id,
transaction_id=transaction_id,
code=code,
amount=penal_amount,
percent=percent,
description=f"Penal Charge {penal_no} for loan {loan_id} schedule {schedule_number}",
due=True,
due_date=now
)
try:
db.session.add(penal_charge)
db.session.commit()
except IntegrityError as err:
db.session.rollback()
raise ValueError(f"Database integrity error: {err}")
return penal_charge
+347 -347
View File
@@ -1,348 +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
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 -259
View File
@@ -1,260 +1,260 @@
from app.extensions import db
from datetime import datetime, timezone
from app.utils.logger import logger
from app.enums.loan_status import LoanStatus
from sqlalchemy.exc import IntegrityError
class Repayment(db.Model):
__tablename__ = "repayments"
id = db.Column(
db.Integer,
primary_key=True,
autoincrement=True,
)
loan_id = db.Column(db.String(50), nullable=False)
customer_id = db.Column(db.String(50), nullable=False)
product_id = db.Column(db.String(20), nullable=True)
transaction_id = db.Column(db.String(50), nullable=False)
initiated_by = db.Column(db.String(50), nullable=True)
salary_amount = db.Column(db.Float, nullable=True, default=0.0)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
repay_date = db.Column(db.DateTime, nullable=True)
verify_date = db.Column(db.DateTime, nullable=True)
repay_result = db.Column(db.String(10), nullable=True)
repay_description = db.Column(db.String(100), nullable=True)
verify_result = db.Column(db.String(10), nullable=True)
verify_description = db.Column(db.String(100), nullable=True)
def __repr__(self):
return f'<Repayment {self.id}>'
def to_dict(self):
"""
Convert the Repayment object to a dictionary format for JSON serialization.
"""
return {
'Id': self.id,
"customerId": self.customer_id,
'loanId': self.loan_id,
'productId': self.product_id,
'repayResult': self.repay_result,
'repayDescription': self.repay_description,
'verifyResult': self.verify_result,
'verifyDescription': self.verify_description,
'transactionId': self.transaction_id,
'initiatedBy':self.initiated_by,
'salaryAmount':self.salary_amount,
'repayDate': self.repay_date.isoformat() if self.repay_date else None,
'VerifyDate': self.verify_date.isoformat() if self.verify_date else None,
}
@classmethod
def create_repayment(cls, repayment_data):
if repayment_data["LoanStatus"] not in [LoanStatus.ACTIVE, LoanStatus.START_REPAY,LoanStatus.ACTIVE_PARTIAL]:
raise ValueError(f"Repayment cannot be processed. Loan status: ({repayment_data['LoanStatus']})")
repayment = cls(
customer_id=repayment_data["customerId"],
loan_id=repayment_data["loanId"],
product_id=repayment_data["productId"],
transaction_id=repayment_data["transactionId"],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
initiated_by= repayment_data["initiatedBy"],
salary_amount=repayment_data["salaryAmount"]
)
try:
db.session.add(repayment)
db.session.commit()
logger.info("Repayment record committed.")
return repayment
except IntegrityError as err:
logger.error(f"Database integrity error: {err}")
return {"error": "Integrity error", "details": str(err)}
@classmethod
def add_repayment(cls, data: dict):
"""
Create and persist a new repayment record.
"""
logger.info(f"Received repayment data: {data}")
try:
new_repayment = cls(
loan_id=data["loanId"],
customer_id=data["customerId"],
product_id=data.get("productId"),
transaction_id=data["transactionId"],
initiated_by=data.get("initiatedBy"),
salary_amount=float(data.get("salaryAmount", 0.0)),
repay_date=(
datetime.strptime(data["repayDate"], "%Y-%m-%d")
.replace(tzinfo=timezone.utc)
if data.get("repayDate")
else None
),
repay_result=data.get("repayResult"),
repay_description=data.get("repayDescription"),
verify_result=data.get("verifyResult"),
verify_description=data.get("verifyDescription"),
verify_date=(
datetime.strptime(data["verifyDate"], "%Y-%m-%d")
.replace(tzinfo=timezone.utc)
if data.get("verifyDate")
else None
),
)
db.session.add(new_repayment)
db.session.commit()
logger.info("Repayment record committed.")
return new_repayment
except Exception as e:
db.session.rollback()
logger.error(f"Error adding repayment data: {e}")
raise
@classmethod
def get_repayment_by_transaction_id(cls, transaction_id):
return cls.query.filter_by(transaction_id=transaction_id).first()
@classmethod
def get_repayment_by_id(cls, id):
return cls.query.filter_by(id=id).first()
@classmethod
def set_repay_date(cls, repayment_id, customer_id):
"""
Update the repay date of the loan with the given loan_id.
"""
# Retrieve repayment
repayment = cls.query.get(repayment_id)
if not repayment:
raise ValueError(f"repayment with ID {repayment_id} does not exist.")
# Check if customer_id matches
if repayment.customer_id != customer_id:
raise ValueError(f"Customer ID {customer_id} does not match the repayment's customer ID.")
current_time = datetime.now()
logger.info(f"What is now ======= ==== ==> : {current_time}")
# Update repayment date
repayment.repay_date = current_time
# Commit changes to database
try:
logger.info(f"Updating repay date for repayment ID {repayment_id} to {current_time}")
db.session.commit()
return repayment.to_dict()
except Exception as e:
db.session.rollback()
logger.error(f"Failed to update repay date: {e}")
raise e
@classmethod
def set_repay_verify_date(cls, repayment_id, customer_id):
"""
Update the repayment verify date of the loan with the given repayment_id.
"""
# Retrieve repayment
repayment = cls.query.get(repayment_id)
if not repayment:
raise ValueError(f"repayment with ID {repayment_id} does not exist.")
# Check if customer_id matches
if repayment.customer_id != customer_id:
raise ValueError(f"Customer ID {customer_id} does not match the repayment's customer ID.")
current_time = datetime.now()
logger.info(f"What is now ======= ==== ==> : {current_time}")
# Update repayment verify_date
repayment.verify_date = current_time
# Commit changes to database
try:
logger.info(f"Updating repay verify date for repayment ID {repayment_id} to {current_time}")
db.session.commit()
except Exception as e:
db.session.rollback()
logger.error(f"Failed to update repay verify date: {e}")
raise e
@classmethod
def set_repay_result(cls, repayment_id, result, description):
logger.info("repay result called")
"""
Update the repayment result and description of the repayment with the given repayment_id.
"""
# Retrieve loan
repayment = cls.query.get(repayment_id)
if not repayment:
raise ValueError(f"repayment with ID {repayment_id} does not exist.")
# Update repayment result and description
repayment.repay_result = result
repayment.repay_description = description
# Commit changes to database
try:
logger.info(f"Updating repayment result for repayment ID {repayment_id} to {result} with description {description}")
db.session.commit()
except Exception as e:
db.session.rollback()
logger.error(f"Failed to update repayment result: {e}")
raise
@classmethod
def set_verify_date_result(cls, repayment_id, result, description):
"""
Update the verify result and description of the repayment with the given repayment_id.
"""
# Retrieve repayment
repayment = cls.query.get(repayment_id)
if not repayment:
raise ValueError(f"repayment with ID {repayment_id} does not exist.")
# Update disburse result and description
repayment.verify_result = result
repayment.verify_description = description
# Commit changes to database
try:
logger.info(f"Updating verify result for repayment ID {repayment_id} to {result} with description {description}")
db.session.commit()
except Exception as e:
db.session.rollback()
logger.error(f"Failed to update verify result: {e}")
raise
@classmethod
def get_latest_repayment_without_repay_date(cls):
"""
Get the latest repayment without a repay date.
"""
return cls.query.filter(
cls.repay_date.is_(None)
).order_by(cls.created_at.desc()).first()
@classmethod
def get_latest_repayment_with_loanId(cls, loan_id):
"""
Get the latest repayment with loan Id.
"""
return cls.query.filter(
cls.loan_id == loan_id
).order_by(cls.created_at.desc()).first()
@classmethod
def get_latest_loan_with_repay_date(cls):
"""
Get the latest repayment with a repay date and no verification date.
"""
return cls.query.filter(
cls.repay_date.isnot(None),
cls.verify_date.is_(None)
from app.extensions import db
from datetime import datetime, timezone
from app.utils.logger import logger
from app.enums.loan_status import LoanStatus
from sqlalchemy.exc import IntegrityError
class Repayment(db.Model):
__tablename__ = "repayments"
id = db.Column(
db.Integer,
primary_key=True,
autoincrement=True,
)
loan_id = db.Column(db.String(50), nullable=False)
customer_id = db.Column(db.String(50), nullable=False)
product_id = db.Column(db.String(20), nullable=True)
transaction_id = db.Column(db.String(50), nullable=False)
initiated_by = db.Column(db.String(50), nullable=True)
salary_amount = db.Column(db.Float, nullable=True, default=0.0)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
repay_date = db.Column(db.DateTime, nullable=True)
verify_date = db.Column(db.DateTime, nullable=True)
repay_result = db.Column(db.String(10), nullable=True)
repay_description = db.Column(db.String(100), nullable=True)
verify_result = db.Column(db.String(10), nullable=True)
verify_description = db.Column(db.String(100), nullable=True)
def __repr__(self):
return f'<Repayment {self.id}>'
def to_dict(self):
"""
Convert the Repayment object to a dictionary format for JSON serialization.
"""
return {
'Id': self.id,
"customerId": self.customer_id,
'loanId': self.loan_id,
'productId': self.product_id,
'repayResult': self.repay_result,
'repayDescription': self.repay_description,
'verifyResult': self.verify_result,
'verifyDescription': self.verify_description,
'transactionId': self.transaction_id,
'initiatedBy':self.initiated_by,
'salaryAmount':self.salary_amount,
'repayDate': self.repay_date.isoformat() if self.repay_date else None,
'VerifyDate': self.verify_date.isoformat() if self.verify_date else None,
}
@classmethod
def create_repayment(cls, repayment_data):
if repayment_data["LoanStatus"] not in [LoanStatus.ACTIVE, LoanStatus.START_REPAY,LoanStatus.ACTIVE_PARTIAL]:
raise ValueError(f"Repayment cannot be processed. Loan status: ({repayment_data['LoanStatus']})")
repayment = cls(
customer_id=repayment_data["customerId"],
loan_id=repayment_data["loanId"],
product_id=repayment_data["productId"],
transaction_id=repayment_data["transactionId"],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
initiated_by= repayment_data["initiatedBy"],
salary_amount=repayment_data["salaryAmount"]
)
try:
db.session.add(repayment)
db.session.commit()
logger.info("Repayment record committed.")
return repayment
except IntegrityError as err:
logger.error(f"Database integrity error: {err}")
return {"error": "Integrity error", "details": str(err)}
@classmethod
def add_repayment(cls, data: dict):
"""
Create and persist a new repayment record.
"""
logger.info(f"Received repayment data: {data}")
try:
new_repayment = cls(
loan_id=data["loanId"],
customer_id=data["customerId"],
product_id=data.get("productId"),
transaction_id=data["transactionId"],
initiated_by=data.get("initiatedBy"),
salary_amount=float(data.get("salaryAmount", 0.0)),
repay_date=(
datetime.strptime(data["repayDate"], "%Y-%m-%d")
.replace(tzinfo=timezone.utc)
if data.get("repayDate")
else None
),
repay_result=data.get("repayResult"),
repay_description=data.get("repayDescription"),
verify_result=data.get("verifyResult"),
verify_description=data.get("verifyDescription"),
verify_date=(
datetime.strptime(data["verifyDate"], "%Y-%m-%d")
.replace(tzinfo=timezone.utc)
if data.get("verifyDate")
else None
),
)
db.session.add(new_repayment)
db.session.commit()
logger.info("Repayment record committed.")
return new_repayment
except Exception as e:
db.session.rollback()
logger.error(f"Error adding repayment data: {e}")
raise
@classmethod
def get_repayment_by_transaction_id(cls, transaction_id):
return cls.query.filter_by(transaction_id=transaction_id).first()
@classmethod
def get_repayment_by_id(cls, id):
return cls.query.filter_by(id=id).first()
@classmethod
def set_repay_date(cls, repayment_id, customer_id):
"""
Update the repay date of the loan with the given loan_id.
"""
# Retrieve repayment
repayment = cls.query.get(repayment_id)
if not repayment:
raise ValueError(f"repayment with ID {repayment_id} does not exist.")
# Check if customer_id matches
if repayment.customer_id != customer_id:
raise ValueError(f"Customer ID {customer_id} does not match the repayment's customer ID.")
current_time = datetime.now()
logger.info(f"What is now ======= ==== ==> : {current_time}")
# Update repayment date
repayment.repay_date = current_time
# Commit changes to database
try:
logger.info(f"Updating repay date for repayment ID {repayment_id} to {current_time}")
db.session.commit()
return repayment.to_dict()
except Exception as e:
db.session.rollback()
logger.error(f"Failed to update repay date: {e}")
raise e
@classmethod
def set_repay_verify_date(cls, repayment_id, customer_id):
"""
Update the repayment verify date of the loan with the given repayment_id.
"""
# Retrieve repayment
repayment = cls.query.get(repayment_id)
if not repayment:
raise ValueError(f"repayment with ID {repayment_id} does not exist.")
# Check if customer_id matches
if repayment.customer_id != customer_id:
raise ValueError(f"Customer ID {customer_id} does not match the repayment's customer ID.")
current_time = datetime.now()
logger.info(f"What is now ======= ==== ==> : {current_time}")
# Update repayment verify_date
repayment.verify_date = current_time
# Commit changes to database
try:
logger.info(f"Updating repay verify date for repayment ID {repayment_id} to {current_time}")
db.session.commit()
except Exception as e:
db.session.rollback()
logger.error(f"Failed to update repay verify date: {e}")
raise e
@classmethod
def set_repay_result(cls, repayment_id, result, description):
logger.info("repay result called")
"""
Update the repayment result and description of the repayment with the given repayment_id.
"""
# Retrieve loan
repayment = cls.query.get(repayment_id)
if not repayment:
raise ValueError(f"repayment with ID {repayment_id} does not exist.")
# Update repayment result and description
repayment.repay_result = result
repayment.repay_description = description
# Commit changes to database
try:
logger.info(f"Updating repayment result for repayment ID {repayment_id} to {result} with description {description}")
db.session.commit()
except Exception as e:
db.session.rollback()
logger.error(f"Failed to update repayment result: {e}")
raise
@classmethod
def set_verify_date_result(cls, repayment_id, result, description):
"""
Update the verify result and description of the repayment with the given repayment_id.
"""
# Retrieve repayment
repayment = cls.query.get(repayment_id)
if not repayment:
raise ValueError(f"repayment with ID {repayment_id} does not exist.")
# Update disburse result and description
repayment.verify_result = result
repayment.verify_description = description
# Commit changes to database
try:
logger.info(f"Updating verify result for repayment ID {repayment_id} to {result} with description {description}")
db.session.commit()
except Exception as e:
db.session.rollback()
logger.error(f"Failed to update verify result: {e}")
raise
@classmethod
def get_latest_repayment_without_repay_date(cls):
"""
Get the latest repayment without a repay date.
"""
return cls.query.filter(
cls.repay_date.is_(None)
).order_by(cls.created_at.desc()).first()
@classmethod
def get_latest_repayment_with_loanId(cls, loan_id):
"""
Get the latest repayment with loan Id.
"""
return cls.query.filter(
cls.loan_id == loan_id
).order_by(cls.created_at.desc()).first()
@classmethod
def get_latest_loan_with_repay_date(cls):
"""
Get the latest repayment with a repay date and no verification date.
"""
return cls.query.filter(
cls.repay_date.isnot(None),
cls.verify_date.is_(None)
).order_by(cls.created_at.desc()).first()
+74 -74
View File
@@ -1,75 +1,75 @@
from datetime import datetime, timezone
from app.extensions import db
from app.utils.logger import logger
class RepaymentsData(db.Model):
__tablename__ = 'repayments_data'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
transaction_id = db.Column(db.String(50), nullable=False)
added_date = db.Column(db.DateTime(timezone=True), default=datetime.now(timezone.utc), nullable=False)
response_code = db.Column(db.String(10), nullable=True)
response_descr = db.Column(db.String(255), nullable=True)
fbn_transaction_id = db.Column(db.String(255),nullable=True)
account_id = db.Column(db.String(50), nullable=True)
customer_id = db.Column(db.String(50), nullable=True)
repayment_amount = db.Column(db.Float, nullable=True)
amount_collected = db.Column(db.Float, nullable=True)
balance = db.Column(db.Float, nullable=True, default=0.0)
def to_dict(self):
return {
"id": self.id,
"transaction_id": self.transaction_id,
"added_date": self.added_date.isoformat() if self.added_date else None,
"response_code": self.response_code,
"response_descr": self.response_descr,
"customerId": self.customer_id,
"accountId": self.account_id,
"fbnTransactionId": self.fbn_transaction_id,
"repaymentAmount": self.repayment_amount,
"amountCollected": self.amount_collected,
"balance": self.balance
}
def __repr__(self):
return f"<RepaymentsData id={self.id}, transaction_id={self.transaction_id}>"
@classmethod
def add_repayment_data(cls, data):
"""
Add a new repayment data entry.
"""
try:
repayment_amount = float(data.get('repaymentAmount', 0.0))
amount_collected = float(data.get('amountCollected', 0.0))
if amount_collected < 0 or repayment_amount < 0:
raise ValueError("Amounts cannot be negative.")
account_balance = round(repayment_amount - amount_collected, 2)
new_data = cls(
transaction_id=data.get('transactionId'),
response_code=data.get('responseCode'),
response_descr=data.get('responseDescr'),
fbn_transaction_id=data.get('fbnTransactionId'),
account_id=data.get('accountId'),
customer_id=data.get('customerId'),
amount_collected=amount_collected,
repayment_amount=repayment_amount,
balance=account_balance,
)
db.session.add(new_data)
db.session.commit()
logger.info("Repayment data committed successfully")
return new_data
except Exception as e:
db.session.rollback()
logger.error(f"Error adding repayment data: {e}")
raise Exception(f"Error adding repayment data: {str(e)}")
from datetime import datetime, timezone
from app.extensions import db
from app.utils.logger import logger
class RepaymentsData(db.Model):
__tablename__ = 'repayments_data'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
transaction_id = db.Column(db.String(50), nullable=False)
added_date = db.Column(db.DateTime(timezone=True), default=datetime.now(timezone.utc), nullable=False)
response_code = db.Column(db.String(10), nullable=True)
response_descr = db.Column(db.String(255), nullable=True)
fbn_transaction_id = db.Column(db.String(255),nullable=True)
account_id = db.Column(db.String(50), nullable=True)
customer_id = db.Column(db.String(50), nullable=True)
repayment_amount = db.Column(db.Float, nullable=True)
amount_collected = db.Column(db.Float, nullable=True)
balance = db.Column(db.Float, nullable=True, default=0.0)
def to_dict(self):
return {
"id": self.id,
"transaction_id": self.transaction_id,
"added_date": self.added_date.isoformat() if self.added_date else None,
"response_code": self.response_code,
"response_descr": self.response_descr,
"customerId": self.customer_id,
"accountId": self.account_id,
"fbnTransactionId": self.fbn_transaction_id,
"repaymentAmount": self.repayment_amount,
"amountCollected": self.amount_collected,
"balance": self.balance
}
def __repr__(self):
return f"<RepaymentsData id={self.id}, transaction_id={self.transaction_id}>"
@classmethod
def add_repayment_data(cls, data):
"""
Add a new repayment data entry.
"""
try:
repayment_amount = float(data.get('repaymentAmount', 0.0))
amount_collected = float(data.get('amountCollected', 0.0))
if amount_collected < 0 or repayment_amount < 0:
raise ValueError("Amounts cannot be negative.")
account_balance = round(repayment_amount - amount_collected, 2)
new_data = cls(
transaction_id=data.get('transactionId'),
response_code=data.get('responseCode'),
response_descr=data.get('responseDescr'),
fbn_transaction_id=data.get('fbnTransactionId'),
account_id=data.get('accountId'),
customer_id=data.get('customerId'),
amount_collected=amount_collected,
repayment_amount=repayment_amount,
balance=account_balance,
)
db.session.add(new_data)
db.session.commit()
logger.info("Repayment data committed successfully")
return new_data
except Exception as e:
db.session.rollback()
logger.error(f"Error adding repayment data: {e}")
raise Exception(f"Error adding repayment data: {str(e)}")
+98 -98
View File
@@ -1,98 +1,98 @@
from app.extensions import db
from datetime import datetime, timezone
from app.utils.logger import logger
class Salary(db.Model):
__tablename__ = "salaries"
id = db.Column(
db.Integer,
primary_key=True,
autoincrement=True,
)
customer_id = db.Column(db.String(50), nullable=False)
account_id = db.Column(db.String(50), nullable=False)
amount = db.Column(db.Float, nullable=True, default=0.0)
status = db.Column(db.String(20), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
salary_date = db.Column(db.DateTime, nullable=True)
def __repr__(self):
return f'<Salary {self.id}>'
def to_dict(self):
"""
Convert the Salary object to a dictionary format for JSON serialization.
"""
return {
'id': self.id,
'customerId': self.customer_id,
'accountId' : self.account_id,
'salaryAmount': self.amount,
'status': self.status,
'createdAt': self.created_at.isoformat() if self.created_at else None,
'updatedAt': self.updated_at.isoformat() if self.updated_at else None,
'salaryDate': self.salary_date.isoformat() if self.salary_date else None,
}
@classmethod
def add_salary_data(cls, data):
"""
Add a new salary data entry.
"""
logger.info(f"Received data:{data}")
try:
new_data = cls(
customer_id=data.get('customerId'),
amount=data.get('salaryAmount', 0.0),
status='START',
salary_date = datetime.strptime(data.get('salaryDate'), "%Y-%m-%d").date() if data.get('salaryDate') else None,
account_id=data.get('accountId')
)
db.session.add(new_data)
db.session.commit()
logger.info("Salary data has been committed.")
return new_data
except Exception as e:
db.session.rollback()
logger.info(f"error : {str(e)}")
raise Exception(f"Error adding salary data: {str(e)}")
@classmethod
def get_pending_salaries(cls):
"""
Retrieve all salary entries with status 'START', ordered by ID ascending.
"""
try:
return cls.query.filter_by(status='START').order_by(cls.id.asc()).all()
except Exception as e:
logger.error(f"Error fetching pending salaries: {str(e)}")
return []
@classmethod
def update_status(cls, salary_id, status):
"""
Update the status of the salary record with the given salary_id.
"""
try:
# Retrieve salary record
salary = cls.query.get(salary_id)
if not salary:
raise ValueError(f"Salary with ID {salary_id} does not exist.")
if salary.status == status:
return salary.to_dict() # Still return the current state if no change
# Update status and timestamp
salary.status = status
salary.updated_at = datetime.now(timezone.utc) # Manually update timestamp if not auto-updating
db.session.commit()
logger.info("Salary status updated and committed.")
return salary.to_dict()
except Exception as e:
db.session.rollback()
logger.error(f"Error updating salary status: {e}")
raise Exception(f"Error updating salary status: {str(e)}")
from app.extensions import db
from datetime import datetime, timezone
from app.utils.logger import logger
class Salary(db.Model):
__tablename__ = "salaries"
id = db.Column(
db.Integer,
primary_key=True,
autoincrement=True,
)
customer_id = db.Column(db.String(50), nullable=False)
account_id = db.Column(db.String(50), nullable=False)
amount = db.Column(db.Float, nullable=True, default=0.0)
status = db.Column(db.String(20), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
salary_date = db.Column(db.DateTime, nullable=True)
def __repr__(self):
return f'<Salary {self.id}>'
def to_dict(self):
"""
Convert the Salary object to a dictionary format for JSON serialization.
"""
return {
'id': self.id,
'customerId': self.customer_id,
'accountId' : self.account_id,
'salaryAmount': self.amount,
'status': self.status,
'createdAt': self.created_at.isoformat() if self.created_at else None,
'updatedAt': self.updated_at.isoformat() if self.updated_at else None,
'salaryDate': self.salary_date.isoformat() if self.salary_date else None,
}
@classmethod
def add_salary_data(cls, data):
"""
Add a new salary data entry.
"""
logger.info(f"Received data:{data}")
try:
new_data = cls(
customer_id=data.get('customerId'),
amount=data.get('salaryAmount', 0.0),
status='START',
salary_date = datetime.strptime(data.get('salaryDate'), "%Y-%m-%d").date() if data.get('salaryDate') else None,
account_id=data.get('accountId')
)
db.session.add(new_data)
db.session.commit()
logger.info("Salary data has been committed.")
return new_data
except Exception as e:
db.session.rollback()
logger.info(f"error : {str(e)}")
raise Exception(f"Error adding salary data: {str(e)}")
@classmethod
def get_pending_salaries(cls):
"""
Retrieve all salary entries with status 'START', ordered by ID ascending.
"""
try:
return cls.query.filter_by(status='START').order_by(cls.id.asc()).all()
except Exception as e:
logger.error(f"Error fetching pending salaries: {str(e)}")
return []
@classmethod
def update_status(cls, salary_id, status):
"""
Update the status of the salary record with the given salary_id.
"""
try:
# Retrieve salary record
salary = cls.query.get(salary_id)
if not salary:
raise ValueError(f"Salary with ID {salary_id} does not exist.")
if salary.status == status:
return salary.to_dict() # Still return the current state if no change
# Update status and timestamp
salary.status = status
salary.updated_at = datetime.now(timezone.utc) # Manually update timestamp if not auto-updating
db.session.commit()
logger.info("Salary status updated and committed.")
return salary.to_dict()
except Exception as e:
db.session.rollback()
logger.error(f"Error updating salary status: {e}")
raise Exception(f"Error updating salary status: {str(e)}")
+67 -67
View File
@@ -1,68 +1,68 @@
from app.extensions import db
from datetime import datetime, timezone
from app.utils.logger import logger
from sqlalchemy import and_, or_, not_
class Transaction(db.Model):
__tablename__ = "transactions"
id = db.Column(
db.Integer,
primary_key=True,
autoincrement=True,
)
transaction_id = db.Column(db.String(50), nullable=False)
account_id = db.Column(db.String(50), nullable=True)
customer_id = db.Column(db.String(50), nullable=True)
type = db.Column(db.String(50), nullable=False)
channel = db.Column(db.String(50), nullable=False)
phone_number = db.Column(db.String(50), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
@classmethod
def create_transaction(cls, transaction_id, account_id, customer_id, type, channel):
logger.error(f"**Setting Transaction {transaction_id} for Type {type}")
if cls.query.filter( and_( cls.transaction_id ==transaction_id, cls.type==type) ).first():
logger.error(f"Transaction already exists for {type}")
return '' # dont raise - do not crash beacause of this
transaction = cls(
transaction_id = transaction_id,
customer_id = customer_id,
account_id = account_id,
type = type,
channel = channel,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
try:
db.session.add(transaction)
db.session.commit()
except IntegrityError as err:
raise ValueError(f"Database integrity error: {err}")
return transaction
def __repr__(self):
return f'<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):
from app.extensions import db
from datetime import datetime, timezone
from app.utils.logger import logger
from sqlalchemy import and_, or_, not_
class Transaction(db.Model):
__tablename__ = "transactions"
id = db.Column(
db.Integer,
primary_key=True,
autoincrement=True,
)
transaction_id = db.Column(db.String(50), nullable=False)
account_id = db.Column(db.String(50), nullable=True)
customer_id = db.Column(db.String(50), nullable=True)
type = db.Column(db.String(50), nullable=False)
channel = db.Column(db.String(50), nullable=False)
phone_number = db.Column(db.String(50), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
@classmethod
def create_transaction(cls, transaction_id, account_id, customer_id, type, channel):
logger.error(f"**Setting Transaction {transaction_id} for Type {type}")
if cls.query.filter( and_( cls.transaction_id ==transaction_id, cls.type==type) ).first():
logger.error(f"Transaction already exists for {type}")
return '' # dont raise - do not crash beacause of this
transaction = cls(
transaction_id = transaction_id,
customer_id = customer_id,
account_id = account_id,
type = type,
channel = channel,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
try:
db.session.add(transaction)
db.session.commit()
except IntegrityError as err:
raise ValueError(f"Database integrity error: {err}")
return transaction
def __repr__(self):
return f'<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()
+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)
+30 -30
View File
@@ -1,30 +1,30 @@
from flask import jsonify
from app.helpers.response_helper import ResponseHelper
def method_not_allowed(error):
return ResponseHelper.method_not_allowed(message="Method Not Allowed")
def not_found(error):
return ResponseHelper.not_found(message="URL Not Found")
def bad_request(error):
return ResponseHelper.bad_request(message="Bad Request")
def unsupported_media_type(error):
return ResponseHelper.error(message="Unsupported Media Type", status_code=415)
def success(data):
return ResponseHelper.success(data=data)
def created(data):
return ResponseHelper.created(data=data)
def updated(data):
return ResponseHelper.updated(data=data)
from flask import jsonify
from app.helpers.response_helper import ResponseHelper
def method_not_allowed(error):
return ResponseHelper.method_not_allowed(message="Method Not Allowed")
def not_found(error):
return ResponseHelper.not_found(message="URL Not Found")
def bad_request(error):
return ResponseHelper.bad_request(message="Bad Request")
def unsupported_media_type(error):
return ResponseHelper.error(message="Unsupported Media Type", status_code=415)
def success(data):
return ResponseHelper.success(data=data)
def created(data):
return ResponseHelper.created(data=data)
def updated(data):
return ResponseHelper.updated(data=data)
+2 -2
View File
@@ -1,2 +1,2 @@
from .authentication import auth_bp
from .autocall import autocall_bp
from .authentication import auth_bp
from .autocall import autocall_bp
+137 -137
View File
@@ -1,137 +1,137 @@
from flask import Blueprint, request, jsonify, current_app
import requests
from app.extensions import db
from sqlalchemy import text
from app.utils.auth import get_headers
from app.config import settings
from app.utils.logger import logger
from app.integrations.bank_service import BankService
auth_bp = Blueprint("auth", __name__)
BASE_URL = settings.BANK_CALL_BASE_URL
@auth_bp.route("/health", methods=["GET"])
def health():
logger.info("Health check endpoint called")
errors = [] # collect all errors
try:
# Detect database type
dialect = db.engine.dialect.name.lower()
logger.info(f"Database dialect detected: {dialect}")
# Build correct query based on DB type
if "oracle" in dialect:
query = text("SELECT 1 FROM dual")
else:
query = text("SELECT 1")
# Test database connection
try:
db.session.execute(query)
logger.info("Database connection successful.")
except Exception as db_err:
logger.error(f"Database connection failed: {str(db_err)}")
errors.append(f"Database connection failed: {str(db_err)}")
# Check Bank Service health
try:
bank_response = BankService.health_check()
logger.info(f"Bank Service health check response: {bank_response}")
except Exception as bank_err:
logger.error(f"Bank Service health check failed: {str(bank_err)}")
errors.append(f"Bank Service health check failed: {str(bank_err)}")
# Build final response
if errors:
return jsonify({
"status": "error",
"database": dialect,
"errors": errors
}), 500
return jsonify({
"status": "success",
"database": dialect,
"db_status": "connected",
"bank_service_status": "operational",
"message": "All systems operational"
}), 200
except Exception as e:
logger.exception("Unexpected error during health check")
return jsonify({
"status": "error",
"errors": [str(e)]
}), 500
@auth_bp.route("/login", methods=["POST"])
def login():
data = request.get_json()
api_url = f"{BASE_URL}/login"
response = requests.post(api_url, json=data)
if response.status_code == 200:
return jsonify(response.json()), 200
return jsonify({"error": "Invalid credentials"}), response.status_code
@auth_bp.route("/status-call", methods=["POST"])
def status_call():
data = request.get_json()
api_url = f"{BASE_URL}/StatusCall"
# response = requests.post(api_url, json=data, headers=get_headers())
# return jsonify(response.json()), response.status_code
response = {
"transactionId": "24110114545374721",
"data": {
"transactionId": "241101",
"providedAmount": 1000,
"collectedAmount": 0,
"resultCode": "00",
"resultDescription": "Loan Provision is successful",
},
"resultCode": "00",
"resultDescription": "SUCCESS",
}
return jsonify(response), 200
@auth_bp.route("/sms", methods=["POST"])
def sms():
data = request.get_json()
api_url = f"{BASE_URL}/SMS"
# response = requests.post(api_url, json=data, headers=get_headers())
# return jsonify(response.json()), response.status_code
response = {
"data": "",
"statusCode": 200,
"IsSuccessful": True,
"errorMessage": None,
}
return jsonify(response), 200
@auth_bp.route("/bulk-sms", methods=["POST"])
def bulk_sms():
data = request.get_json()
api_url = f"{BASE_URL}/BulkSMS"
# response = requests.post(api_url, json=data, headers=get_headers())
# return jsonify(response.json()), response.status_code
response = {
"data": "",
"statusCode": 200,
"IsSuccessful": True,
"errorMessage": None,
}
return jsonify(response), 200
from flask import Blueprint, request, jsonify, current_app
import requests
from app.extensions import db
from sqlalchemy import text
from app.utils.auth import get_headers
from app.config import settings
from app.utils.logger import logger
from app.integrations.bank_service import BankService
auth_bp = Blueprint("auth", __name__)
BASE_URL = settings.BANK_CALL_BASE_URL
@auth_bp.route("/health", methods=["GET"])
def health():
logger.info("Health check endpoint called")
errors = [] # collect all errors
try:
# Detect database type
dialect = db.engine.dialect.name.lower()
logger.info(f"Database dialect detected: {dialect}")
# Build correct query based on DB type
if "oracle" in dialect:
query = text("SELECT 1 FROM dual")
else:
query = text("SELECT 1")
# Test database connection
try:
db.session.execute(query)
logger.info("Database connection successful.")
except Exception as db_err:
logger.error(f"Database connection failed: {str(db_err)}")
errors.append(f"Database connection failed: {str(db_err)}")
# Check Bank Service health
try:
bank_response = BankService.health_check()
logger.info(f"Bank Service health check response: {bank_response}")
except Exception as bank_err:
logger.error(f"Bank Service health check failed: {str(bank_err)}")
errors.append(f"Bank Service health check failed: {str(bank_err)}")
# Build final response
if errors:
return jsonify({
"status": "error",
"database": dialect,
"errors": errors
}), 500
return jsonify({
"status": "success",
"database": dialect,
"db_status": "connected",
"bank_service_status": "operational",
"message": "All systems operational"
}), 200
except Exception as e:
logger.exception("Unexpected error during health check")
return jsonify({
"status": "error",
"errors": [str(e)]
}), 500
@auth_bp.route("/login", methods=["POST"])
def login():
data = request.get_json()
api_url = f"{BASE_URL}/login"
response = requests.post(api_url, json=data)
if response.status_code == 200:
return jsonify(response.json()), 200
return jsonify({"error": "Invalid credentials"}), response.status_code
@auth_bp.route("/status-call", methods=["POST"])
def status_call():
data = request.get_json()
api_url = f"{BASE_URL}/StatusCall"
# response = requests.post(api_url, json=data, headers=get_headers())
# return jsonify(response.json()), response.status_code
response = {
"transactionId": "24110114545374721",
"data": {
"transactionId": "241101",
"providedAmount": 1000,
"collectedAmount": 0,
"resultCode": "00",
"resultDescription": "Loan Provision is successful",
},
"resultCode": "00",
"resultDescription": "SUCCESS",
}
return jsonify(response), 200
@auth_bp.route("/sms", methods=["POST"])
def sms():
data = request.get_json()
api_url = f"{BASE_URL}/SMS"
# response = requests.post(api_url, json=data, headers=get_headers())
# return jsonify(response.json()), response.status_code
response = {
"data": "",
"statusCode": 200,
"IsSuccessful": True,
"errorMessage": None,
}
return jsonify(response), 200
@auth_bp.route("/bulk-sms", methods=["POST"])
def bulk_sms():
data = request.get_json()
api_url = f"{BASE_URL}/BulkSMS"
# response = requests.post(api_url, json=data, headers=get_headers())
# return jsonify(response.json()), response.status_code
response = {
"data": "",
"statusCode": 200,
"IsSuccessful": True,
"errorMessage": None,
}
return jsonify(response), 200
+784 -684
View File
File diff suppressed because it is too large Load Diff
+162 -147
View File
@@ -1,148 +1,163 @@
from app.models import Loan, LoanCharge
from app.utils.logger import logger
from app.enums.loan_status import LoanStatus
from decimal import Decimal, ROUND_HALF_UP
from app.services.loan_repayment_schedule import LoanRepaymentScheduleService
class LoanService:
@classmethod
def get_loan_by_transaction_id(cls, transaction_id):
"""
Get the loan by transaction ID
"""
return Loan.get_loan_by_transaction_id(transaction_id)
@classmethod
def get_loan_by_loan_id(cls, loan_id):
"""
Get the loan by ID
"""
return Loan.get_loan_by_loan_id(loan_id)
@classmethod
def get_loan_by_debt_id(cls, debt_id):
"""
Get the loan by transaction ID
"""
return Loan.get_loan_by_debt_id(debt_id)
@classmethod
def get_loan_charge_by_debt_id(cls, debt_id):
"""
Get the loan charge by debt ID
"""
return LoanCharge.get_loan_charge_by_debt_id(debt_id)
@classmethod
def set_disbursement_date(cls, loan_id, customer_id):
"""
Update the disbursement status of the loan with the given loan_id.
"""
return Loan.set_disbursement_date(loan_id, customer_id)
@classmethod
def set_disburse_verify_date(cls, loan_id, customer_id):
"""
Update the disburse verify date of the loan with the given loan_id.
"""
return Loan.set_disburse_verify_date(loan_id, customer_id)
@classmethod
def set_disbursement_result(cls, loan_id, result, description):
"""
Update the disbursement result of the loan with the given loan_id.
"""
return Loan.set_disbursement_result(loan_id, result, description)
@classmethod
def set_disbursement_loan_description(cls,loan_id,description):
return Loan.set_disbursement_message(loan_id, description)
@classmethod
def set_disburse_verify_result(cls, loan_id, result, description):
"""
Update the disburse verify result of the loan with the given loan_id.
"""
return Loan.set_disburse_verify_result(loan_id, result, description)
@classmethod
def get_latest_loan_without_disburse_date(cls):
"""
Get the latest loan without a disbursement date.
"""
return Loan.get_latest_loan_without_disburse_date()
@classmethod
def get_latest_loan_with_disburse_date(cls):
"""
Get the latest loan without a disbursement date.
"""
return Loan.get_latest_loan_with_disburse_date()
@classmethod
def get_customer_loans(cls, customer_id):
"""
Get customer's active loans by customer_id.
"""
return Loan.get_customer_loans(customer_id=customer_id)
@classmethod
def get_customer_active_loans(cls, customer_id):
"""
Get customer's active loans by customer_id.
"""
return Loan.get_customer_active_loans(customer_id=customer_id)
@classmethod
def update_status(cls, loan_id, status):
"""
Update the status of the loan with the given loan_id.
"""
# Retrieve loan
return Loan.update_status(loan_id, status)
@classmethod
def update_loan_balance(cls,loan_id,amount_collected):
"""
update the loan balance after successful repayment
"""
return Loan.update_loan_balance(loan_id,amount_collected)
@classmethod
def get_overdue_loans(cls):
"""
Get all overdue loans.
"""
return Loan.get_overdue_loans()
@classmethod
def apply_penal_to_loan(cls,loan_id,penal_charge):
return Loan.apply_penal_to_loan(loan_id,penal_charge)
@staticmethod
def _update_loan_after_collection(loan, loan_data, updated_loan, amount_collected, data, response_message):
if loan.balance is None or loan.balance <= 0:
logger.warning(f"Loan ID {loan.id} has no balance. Skipping loan update.")
updated_loan = loan.to_dict()
else:
updated_loan = LoanService.update_loan_balance(int(loan_data['debtId']), amount_collected)
updated_balance = Decimal(str(updated_loan['balance'])).quantize(Decimal('0.01'))
if updated_balance <= Decimal('0.00'):
updated_loan = LoanService.update_status(updated_loan['debtId'], LoanStatus.REPAID)
else:
updated_loan = LoanService.update_status(updated_loan['debtId'], LoanStatus.ACTIVE_PARTIAL)
logger.info(f"Updated loan status: {updated_loan.get('status')}")
# lets update the loan repayment schedule
LoanRepaymentScheduleService.handle_schedule_updates(
updated_loan=updated_loan,
data=data,
amount_collected=amount_collected,
message=response_message,
loan_data=loan_data
)
return updated_loan
from app.models import Loan, LoanCharge
from app.utils.logger import logger
from app.enums.loan_status import LoanStatus
from decimal import Decimal, ROUND_HALF_UP
from app.services.loan_repayment_schedule import LoanRepaymentScheduleService
class LoanService:
@classmethod
def get_loan_by_transaction_id(cls, transaction_id):
"""
Get the loan by transaction ID
"""
return Loan.get_loan_by_transaction_id(transaction_id)
@classmethod
def get_loan_by_loan_id(cls, loan_id):
"""
Get the loan by ID
"""
return Loan.get_loan_by_loan_id(loan_id)
@classmethod
def get_loan_by_debt_id(cls, debt_id):
"""
Get the loan by transaction ID
"""
return Loan.get_loan_by_debt_id(debt_id)
@classmethod
def get_loan_charge_by_debt_id(cls, debt_id):
"""
Get the loan charge by debt ID
"""
return LoanCharge.get_loan_charge_by_debt_id(debt_id)
@classmethod
def set_disbursement_date(cls, loan_id, customer_id):
"""
Update the disbursement status of the loan with the given loan_id.
"""
return Loan.set_disbursement_date(loan_id, customer_id)
@classmethod
def set_disburse_verify_date(cls, loan_id, customer_id):
"""
Update the disburse verify date of the loan with the given loan_id.
"""
return Loan.set_disburse_verify_date(loan_id, customer_id)
@classmethod
def set_disbursement_result(cls, loan_id, result, description):
"""
Update the disbursement result of the loan with the given loan_id.
"""
return Loan.set_disbursement_result(loan_id, result, description)
@classmethod
def set_disbursement_loan_description(cls,loan_id,description):
return Loan.set_disbursement_message(loan_id, description)
@classmethod
def get_failed_disbursements(cls):
"""
Get all loans with failed disbursement.
"""
return Loan.get_failed_disbursements()
@classmethod
def update_status(cls, loan_id, status):
"""
Update the status of the loan with the given loan_id.
"""
# Retrieve loan
return Loan.update_status(loan_id, status)
@classmethod
def set_disburse_verify_result(cls, loan_id, result, description):
"""
Update the disburse verify result of the loan with the given loan_id.
"""
return Loan.set_disburse_verify_result(loan_id, result, description)
@classmethod
def get_latest_loan_without_disburse_date(cls):
"""
Get the latest loan without a disbursement date.
"""
return Loan.get_latest_loan_without_disburse_date()
@classmethod
def get_latest_loan_with_disburse_date(cls):
"""
Get the latest loan without a disbursement date.
"""
return Loan.get_latest_loan_with_disburse_date()
@classmethod
def get_customer_loans(cls, customer_id):
"""
Get customer's active loans by customer_id.
"""
return Loan.get_customer_loans(customer_id=customer_id)
@classmethod
def get_customer_active_loans(cls, customer_id):
"""
Get customer's active loans by customer_id.
"""
return Loan.get_customer_active_loans(customer_id=customer_id)
@classmethod
def update_status(cls, loan_id, status):
"""
Update the status of the loan with the given loan_id.
"""
# Retrieve loan
return Loan.update_status(loan_id, status)
@classmethod
def update_loan_balance(cls,loan_id,amount_collected):
"""
update the loan balance after successful repayment
"""
return Loan.update_loan_balance(loan_id,amount_collected)
@classmethod
def get_overdue_loans(cls):
"""
Get all overdue loans.
"""
return Loan.get_overdue_loans()
@classmethod
def apply_penal_to_loan(cls,loan_id,penal_charge):
return Loan.apply_penal_to_loan(loan_id,penal_charge)
@staticmethod
def _update_loan_after_collection(loan, loan_data, updated_loan, amount_collected, data, response_message):
if loan.balance is None or loan.balance <= 0:
logger.warning(f"Loan ID {loan.id} has no balance. Skipping loan update.")
updated_loan = loan.to_dict()
else:
updated_loan = LoanService.update_loan_balance(int(loan_data['debtId']), amount_collected)
updated_balance = Decimal(str(updated_loan['balance'])).quantize(Decimal('0.01'))
if updated_balance <= Decimal('0.00'):
updated_loan = LoanService.update_status(updated_loan['debtId'], LoanStatus.REPAID)
else:
updated_loan = LoanService.update_status(updated_loan['debtId'], LoanStatus.ACTIVE_PARTIAL)
logger.info(f"Updated loan status: {updated_loan.get('status')}")
# lets update the loan repayment schedule
LoanRepaymentScheduleService.handle_schedule_updates(
updated_loan=updated_loan,
data=data,
amount_collected=amount_collected,
message=response_message,
loan_data=loan_data
)
return updated_loan
+12 -12
View File
@@ -1,13 +1,13 @@
from app.models.loan_charge import LoanCharge
class LoanChargesService:
@classmethod
def create_penal_charges_for_loan(cls, loan_id, transaction_id, percent, penal_no, schedule_number, penal_amount=0.0,):
return LoanCharge.create_penal_charges_for_loan(loan_id, transaction_id, percent, penal_no,schedule_number, penal_amount)
@classmethod
def get_last_penal_no(cls,loan_id):
return LoanCharge.get_last_penal_no(loan_id)
@classmethod
def get_penal_charges_by_loan_id(cls,loan_id):
from app.models.loan_charge import LoanCharge
class LoanChargesService:
@classmethod
def create_penal_charges_for_loan(cls, loan_id, transaction_id, percent, penal_no, schedule_number, penal_amount=0.0,):
return LoanCharge.create_penal_charges_for_loan(loan_id, transaction_id, percent, penal_no,schedule_number, penal_amount)
@classmethod
def get_last_penal_no(cls,loan_id):
return LoanCharge.get_last_penal_no(loan_id)
@classmethod
def get_penal_charges_by_loan_id(cls,loan_id):
return LoanCharge.get_penal_charges_by_loan_id(loan_id)
+136 -136
View File
@@ -1,136 +1,136 @@
from app.models.loan_repayment_schedule import LoanRepaymentSchedule
from app.utils.logger import logger
from app.enums.loan_status import LoanStatus
from decimal import Decimal, ROUND_HALF_UP
class LoanRepaymentScheduleService:
@classmethod
def get_repayment_schedule_by_loan_id(cls, loan_id, include_paid=True):
return LoanRepaymentSchedule.get_repayment_schedule_by_loan_id(loan_id, include_paid=include_paid)
@classmethod
def get_overdue_repayment_schedule(cls):
return LoanRepaymentSchedule.get_overdue_repayment_schedule()
@classmethod
def get_active_overdue_repayment_schedule(cls):
return LoanRepaymentSchedule.get_active_overdue_repayment_schedule()
@classmethod
def get_partially_paid_overdue_repayment_schedule(cls):
return LoanRepaymentSchedule.get_partially_paid_overdue_repayment_schedule()
@classmethod
def get_repayment_schedule_by_id_and_transaction_id(cls, id, transaction_id):
return LoanRepaymentSchedule.get_repayment_schedule_by_id_and_transaction_id(id, transaction_id)
@classmethod
def get_repayment_schedule_by_transaction_id(cls, transaction_id):
return LoanRepaymentSchedule.get_repayment_schedule_by_transaction_id(transaction_id)
@classmethod
def update_repayment_schedule_status(cls, schedule_id):
"""
Update repayment schedule status.
"""
return LoanRepaymentSchedule.update_repayment_schedule_status(schedule_id)
@classmethod
def update_repayment_schedule_status_to_active(cls, schedule_id):
"""
Update repayment schedule status.
"""
return LoanRepaymentSchedule.update_repayment_schedule_status_to_active(schedule_id)
@classmethod
def update_repayment_schedule_balance(cls, schedule_id, amount_collected):
"""
Update repayment schedule balance.
"""
return LoanRepaymentSchedule.update_repayment_schedule_balance(schedule_id, amount_collected)
@classmethod
def calculate_penal_charge(cls, schedule):
"""
Calculate penal charge for a repayment schedule.
"""
return LoanRepaymentSchedule.calculate_penal_charge(schedule)
@classmethod
def update_repayment_schedule_description(cls, schedule_id, description):
"""
Update repayment schedule description.
"""
return LoanRepaymentSchedule.update_repayment_schedule_description(schedule_id, description)
@classmethod
def get_overdue_repayment_schedule_with_grace_period(cls, grace_period_days, limit=None):
return LoanRepaymentSchedule.get_overdue_repayment_schedule_with_grace_period(grace_period_days, limit=limit)
@classmethod
def apply_penal_to_schedule(cls, schedule_id, penal_amount):
"""
Apply penal charge to a repayment schedule.
"""
return LoanRepaymentSchedule.apply_penal_to_schedule(schedule_id, penal_amount)
@staticmethod
def handle_schedule_updates(updated_loan, data, amount_collected, message, loan_data):
"""
Handles updating loan repayment schedules depending on loan status
and overdue schedule data.
"""
try:
# Case 1: Loan fully repaid → mark all schedules paid
if updated_loan and updated_loan.get('status') == LoanStatus.REPAID:
repayment_schedule = LoanRepaymentScheduleService.get_repayment_schedule_by_loan_id(
updated_loan['debtId'], include_paid=False
)
logger.info(f'Loan repayment schedule: {repayment_schedule}')
if repayment_schedule:
for installment in repayment_schedule:
try:
logger.info(f'Processing installment: {installment}')
LoanRepaymentScheduleService.update_repayment_schedule_status(installment.id)
LoanRepaymentScheduleService.update_repayment_schedule_description(
installment.id,
message
)
logger.info(f'Updated installment {installment.id} as paid')
except Exception as e:
logger.error(f"Failed to update installment {installment.id}: {e}")
logger.info('All installments processed')
# Case 2: Partial repayment made on a full loan without overdueLoanScheduleId
elif updated_loan and updated_loan.get('status') == LoanStatus.ACTIVE_PARTIAL and not data.get('overdueLoanScheduleId'):
logger.info("Partial repayment detected, but no overdue schedule ID provided.")
# TODO: implement proportional installment updates
# Case 3: when we are processing Overdue schedule repayment → update balance & description
elif data.get('overdueLoanScheduleId') is not None:
logger.info(f"Overdue loan schedule ID: {data['overdueLoanScheduleId']}")
try:
schedule_to_update = LoanRepaymentScheduleService.get_repayment_schedule_by_id_and_transaction_id(
data["overdueLoanScheduleId"], data["transactionId"]
)
logger.info(f"Schedule to update: {schedule_to_update}")
if schedule_to_update is None:
logger.warning(
f"Repayment schedule not found for ID {data['overdueLoanScheduleId']} "
f"and transaction ID {loan_data['transactionId']}"
)
else:
if not schedule_to_update.paid:
update_schedule_balance = LoanRepaymentScheduleService.update_repayment_schedule_balance(
schedule_to_update.id, amount_collected
)
logger.info(f"Updated loan schedule balance: {update_schedule_balance}")
LoanRepaymentScheduleService.update_repayment_schedule_description(
schedule_to_update.id,
message
)
except Exception as e:
logger.error(f"Failed to update repayment schedule installment: {e}")
except Exception as e:
logger.error(f"Unexpected error while handling schedule updates: {e}")
from app.models.loan_repayment_schedule import LoanRepaymentSchedule
from app.utils.logger import logger
from app.enums.loan_status import LoanStatus
from decimal import Decimal, ROUND_HALF_UP
class LoanRepaymentScheduleService:
@classmethod
def get_repayment_schedule_by_loan_id(cls, loan_id, include_paid=True):
return LoanRepaymentSchedule.get_repayment_schedule_by_loan_id(loan_id, include_paid=include_paid)
@classmethod
def get_overdue_repayment_schedule(cls):
return LoanRepaymentSchedule.get_overdue_repayment_schedule()
@classmethod
def get_active_overdue_repayment_schedule(cls):
return LoanRepaymentSchedule.get_active_overdue_repayment_schedule()
@classmethod
def get_partially_paid_overdue_repayment_schedule(cls):
return LoanRepaymentSchedule.get_partially_paid_overdue_repayment_schedule()
@classmethod
def get_repayment_schedule_by_id_and_transaction_id(cls, id, transaction_id):
return LoanRepaymentSchedule.get_repayment_schedule_by_id_and_transaction_id(id, transaction_id)
@classmethod
def get_repayment_schedule_by_transaction_id(cls, transaction_id):
return LoanRepaymentSchedule.get_repayment_schedule_by_transaction_id(transaction_id)
@classmethod
def update_repayment_schedule_status(cls, schedule_id):
"""
Update repayment schedule status.
"""
return LoanRepaymentSchedule.update_repayment_schedule_status(schedule_id)
@classmethod
def update_repayment_schedule_status_to_active(cls, schedule_id):
"""
Update repayment schedule status.
"""
return LoanRepaymentSchedule.update_repayment_schedule_status_to_active(schedule_id)
@classmethod
def update_repayment_schedule_balance(cls, schedule_id, amount_collected):
"""
Update repayment schedule balance.
"""
return LoanRepaymentSchedule.update_repayment_schedule_balance(schedule_id, amount_collected)
@classmethod
def calculate_penal_charge(cls, schedule):
"""
Calculate penal charge for a repayment schedule.
"""
return LoanRepaymentSchedule.calculate_penal_charge(schedule)
@classmethod
def update_repayment_schedule_description(cls, schedule_id, description):
"""
Update repayment schedule description.
"""
return LoanRepaymentSchedule.update_repayment_schedule_description(schedule_id, description)
@classmethod
def get_overdue_repayment_schedule_with_grace_period(cls, grace_period_days, limit=None):
return LoanRepaymentSchedule.get_overdue_repayment_schedule_with_grace_period(grace_period_days, limit=limit)
@classmethod
def apply_penal_to_schedule(cls, schedule_id, penal_amount):
"""
Apply penal charge to a repayment schedule.
"""
return LoanRepaymentSchedule.apply_penal_to_schedule(schedule_id, penal_amount)
@staticmethod
def handle_schedule_updates(updated_loan, data, amount_collected, message, loan_data):
"""
Handles updating loan repayment schedules depending on loan status
and overdue schedule data.
"""
try:
# Case 1: Loan fully repaid → mark all schedules paid
if updated_loan and updated_loan.get('status') == LoanStatus.REPAID:
repayment_schedule = LoanRepaymentScheduleService.get_repayment_schedule_by_loan_id(
updated_loan['debtId'], include_paid=False
)
logger.info(f'Loan repayment schedule: {repayment_schedule}')
if repayment_schedule:
for installment in repayment_schedule:
try:
logger.info(f'Processing installment: {installment}')
LoanRepaymentScheduleService.update_repayment_schedule_status(installment.id)
LoanRepaymentScheduleService.update_repayment_schedule_description(
installment.id,
message
)
logger.info(f'Updated installment {installment.id} as paid')
except Exception as e:
logger.error(f"Failed to update installment {installment.id}: {e}")
logger.info('All installments processed')
# Case 2: Partial repayment made on a full loan without overdueLoanScheduleId
elif updated_loan and updated_loan.get('status') == LoanStatus.ACTIVE_PARTIAL and not data.get('overdueLoanScheduleId'):
logger.info("Partial repayment detected, but no overdue schedule ID provided.")
# TODO: implement proportional installment updates
# Case 3: when we are processing Overdue schedule repayment → update balance & description
elif data.get('overdueLoanScheduleId') is not None:
logger.info(f"Overdue loan schedule ID: {data['overdueLoanScheduleId']}")
try:
schedule_to_update = LoanRepaymentScheduleService.get_repayment_schedule_by_id_and_transaction_id(
data["overdueLoanScheduleId"], data["transactionId"]
)
logger.info(f"Schedule to update: {schedule_to_update}")
if schedule_to_update is None:
logger.warning(
f"Repayment schedule not found for ID {data['overdueLoanScheduleId']} "
f"and transaction ID {loan_data['transactionId']}"
)
else:
if not schedule_to_update.paid:
update_schedule_balance = LoanRepaymentScheduleService.update_repayment_schedule_balance(
schedule_to_update.id, amount_collected
)
logger.info(f"Updated loan schedule balance: {update_schedule_balance}")
LoanRepaymentScheduleService.update_repayment_schedule_description(
schedule_to_update.id,
message
)
except Exception as e:
logger.error(f"Failed to update repayment schedule installment: {e}")
except Exception as e:
logger.error(f"Unexpected error while handling schedule updates: {e}")
+77 -77
View File
@@ -1,78 +1,78 @@
from app.models import Repayment
class RepaymentService:
@staticmethod
def get_repayment_by_transaction_id(transaction_id):
"""
Get the repayment by transaction ID
"""
return Repayment.get_repayment_by_transaction_id(transaction_id)
@staticmethod
def get_repayment_by_id(id):
"""
Get the repayment by ID
"""
return Repayment.get_repayment_by_id(id)
@classmethod
def set_repay_date(cls, repayment_id, customer_id):
"""
Update the repay status of the repayment with the given repayment_id.
"""
return Repayment.set_repay_date(repayment_id, customer_id)
@classmethod
def set_repay_verify_date(cls, repayment_id, customer_id):
"""
Update the verify date of the repayment with the given repayment_id.
"""
return Repayment.set_repay_verify_date(repayment_id, customer_id)
@classmethod
def set_repay_result(cls, repayment_id, result, description):
"""
Update the repay result of the repayment with the given repayment_id.
"""
return Repayment.set_repay_result(repayment_id, result, description)
@classmethod
def set_verify_date_result(cls, repayment_id, result, description):
"""
Update the verify result of the repayment with the given repayment_id.
"""
return Repayment.set_verify_date_result(repayment_id, result, description)
@classmethod
def get_latest_repayment_without_repay_date(cls):
"""
Get the latest repayment without a repay date.
"""
return Repayment.get_latest_repayment_without_repay_date()
@classmethod
def get_latest_repayment_with_loanId(cls,loan_id):
"""
Get the latest repayment with loan id.
"""
return Repayment.get_latest_repayment_with_loanId(loan_id)
@classmethod
def get_latest_loan_with_repay_date(cls):
"""
Get the latest repayment with a repay date and no verification date.
"""
return Repayment.get_latest_loan_with_repay_date()
@classmethod
def add_repayment(cls, data):
"""
Add a new repayment entry.
"""
return Repayment.add_repayment(data)
@classmethod
def create_repayment(cls, repayment_data):
"""
Add a new repayment entry.
"""
from app.models import Repayment
class RepaymentService:
@staticmethod
def get_repayment_by_transaction_id(transaction_id):
"""
Get the repayment by transaction ID
"""
return Repayment.get_repayment_by_transaction_id(transaction_id)
@staticmethod
def get_repayment_by_id(id):
"""
Get the repayment by ID
"""
return Repayment.get_repayment_by_id(id)
@classmethod
def set_repay_date(cls, repayment_id, customer_id):
"""
Update the repay status of the repayment with the given repayment_id.
"""
return Repayment.set_repay_date(repayment_id, customer_id)
@classmethod
def set_repay_verify_date(cls, repayment_id, customer_id):
"""
Update the verify date of the repayment with the given repayment_id.
"""
return Repayment.set_repay_verify_date(repayment_id, customer_id)
@classmethod
def set_repay_result(cls, repayment_id, result, description):
"""
Update the repay result of the repayment with the given repayment_id.
"""
return Repayment.set_repay_result(repayment_id, result, description)
@classmethod
def set_verify_date_result(cls, repayment_id, result, description):
"""
Update the verify result of the repayment with the given repayment_id.
"""
return Repayment.set_verify_date_result(repayment_id, result, description)
@classmethod
def get_latest_repayment_without_repay_date(cls):
"""
Get the latest repayment without a repay date.
"""
return Repayment.get_latest_repayment_without_repay_date()
@classmethod
def get_latest_repayment_with_loanId(cls,loan_id):
"""
Get the latest repayment with loan id.
"""
return Repayment.get_latest_repayment_with_loanId(loan_id)
@classmethod
def get_latest_loan_with_repay_date(cls):
"""
Get the latest repayment with a repay date and no verification date.
"""
return Repayment.get_latest_loan_with_repay_date()
@classmethod
def add_repayment(cls, data):
"""
Add a new repayment entry.
"""
return Repayment.add_repayment(data)
@classmethod
def create_repayment(cls, repayment_data):
"""
Add a new repayment entry.
"""
return Repayment.create_repayment(repayment_data)
+10 -10
View File
@@ -1,11 +1,11 @@
from app.models import RepaymentsData
class RepaymentService:
@classmethod
def add_repayment_data(cls,data):
"""
Add a new repayment data entry.
"""
from app.models import RepaymentsData
class RepaymentService:
@classmethod
def add_repayment_data(cls,data):
"""
Add a new repayment data entry.
"""
return RepaymentsData.add_repayment_data(data)
+23 -23
View File
@@ -1,24 +1,24 @@
from app.models import Salary
class SalaryService:
@classmethod
def add_salary_data(cls,data):
"""
Add a new salary data entry.
"""
return Salary.add_salary_data(data)
@classmethod
def get_pending_salaries(cls):
"""
Get the pending salary for a given customer.
"""
return Salary.get_pending_salaries()
@classmethod
def update_status(cls, salary_id, status):
"""
Update the status of the salary with the given salary_id.
"""
from app.models import Salary
class SalaryService:
@classmethod
def add_salary_data(cls,data):
"""
Add a new salary data entry.
"""
return Salary.add_salary_data(data)
@classmethod
def get_pending_salaries(cls):
"""
Get the pending salary for a given customer.
"""
return Salary.get_pending_salaries()
@classmethod
def update_status(cls, salary_id, status):
"""
Update the status of the salary with the given salary_id.
"""
return Salary.update_status(salary_id, status)
+17 -17
View File
@@ -1,17 +1,17 @@
from app.models import Transaction
class TransactionService:
@staticmethod
def get_transaction_by_transaction_id(transaction_id):
"""
Get the transaction by ID
"""
return Transaction.get_transaction_by_transaction_id(transaction_id)
@staticmethod
def create_transaction(transaction_id, account_id, customer_id, type, channel):
"""
Create Transaction Entry
"""
return Transaction.create_transaction(transaction_id, account_id, customer_id, type, channel)
from app.models import Transaction
class TransactionService:
@staticmethod
def get_transaction_by_transaction_id(transaction_id):
"""
Get the transaction by ID
"""
return Transaction.get_transaction_by_transaction_id(transaction_id)
@staticmethod
def create_transaction(transaction_id, account_id, customer_id, type, channel):
"""
Create Transaction Entry
"""
return Transaction.create_transaction(transaction_id, account_id, customer_id, type, channel)
+45 -45
View File
@@ -1,45 +1,45 @@
from app.config import settings
import requests
from app.utils.logger import logger
def get_headers():
BANK_CALL_BASE_URL = settings.BANK_CALL_BASE_URL
BANK_CALL_AUTH_ENDPOINT = settings.BANK_CALL_AUTH_ENDPOINT
BANK_CALL_BASIC_AUTH_USERNAME = settings.BANK_CALL_BASIC_AUTH_USERNAME
BANK_CALL_BASIC_AUTH_PASSWORD = settings.BANK_CALL_BASIC_AUTH_PASSWORD
BANK_GRANT_TYPE = settings.BANK_GRANT_TYPE
#authenticate
url = f"{BANK_CALL_BASE_URL}{BANK_CALL_AUTH_ENDPOINT}"
data = {
"grant_type": BANK_GRANT_TYPE,
"username": BANK_CALL_BASIC_AUTH_USERNAME,
"password": "G7$k9@pL2!qR"
}
logger.info(f"Calling Bank Call-Auth Endpoint: {url}")
headers = {"Content-Type": "application/json"}
try:
response = requests.post(url, json=data, headers=headers, timeout=10)
response.raise_for_status() # Raises HTTPError for 4xx/5xx
result = response.json()
# Check if access_token is present
if 'access_token' not in result:
logger.error("No access_token found in Bank Call Auth response")
return {"error": "Authentication failed: no access_token returned"}
return {
"Content-Type": "application/json",
"x-api-key": settings.BANK_CALL_API_KEY,
"App-Id": settings.BANK_CALL_APP_ID,
"Authorization": f"Bearer {result['access_token']}"
}
except requests.exceptions.RequestException as e:
logger.error(f"Failed to get auth token: {e}")
raise
except ValueError as e:
logger.error(f"Failed to parse auth response JSON: {e}")
raise
from app.config import settings
import requests
from app.utils.logger import logger
def get_headers():
BANK_CALL_BASE_URL = settings.BANK_CALL_BASE_URL
BANK_CALL_AUTH_ENDPOINT = settings.BANK_CALL_AUTH_ENDPOINT
BANK_CALL_BASIC_AUTH_USERNAME = settings.BANK_CALL_BASIC_AUTH_USERNAME
BANK_CALL_BASIC_AUTH_PASSWORD = settings.BANK_CALL_BASIC_AUTH_PASSWORD
BANK_GRANT_TYPE = settings.BANK_GRANT_TYPE
#authenticate
url = f"{BANK_CALL_BASE_URL}{BANK_CALL_AUTH_ENDPOINT}"
data = {
"grant_type": BANK_GRANT_TYPE,
"username": BANK_CALL_BASIC_AUTH_USERNAME,
"password": "G7$k9@pL2!qR"
}
logger.info(f"Calling Bank Call-Auth Endpoint: {url}")
headers = {"Content-Type": "application/json"}
try:
response = requests.post(url, json=data, headers=headers, timeout=10)
response.raise_for_status() # Raises HTTPError for 4xx/5xx
result = response.json()
# Check if access_token is present
if 'access_token' not in result:
logger.error("No access_token found in Bank Call Auth response")
return {"error": "Authentication failed: no access_token returned"}
return {
"Content-Type": "application/json",
"x-api-key": settings.BANK_CALL_API_KEY,
"App-Id": settings.BANK_CALL_APP_ID,
"Authorization": f"Bearer {result['access_token']}"
}
except requests.exceptions.RequestException as e:
logger.error(f"Failed to get auth token: {e}")
raise
except ValueError as e:
logger.error(f"Failed to parse auth response JSON: {e}")
raise
+15 -15
View File
@@ -1,16 +1,16 @@
def preprocess_loan_charges_data(data):
"""
Preprocesses the data into a dictionary for efficient lookups by 'code'.
Args:
data: A list of dictionaries.
Returns:
A dictionary where keys are 'code' values and values are the corresponding dictionaries from the input data.
If multiple items have the same code, the last one encountered will be stored.
"""
preprocessed = {}
for item in data:
if 'code' in item:
preprocessed[item['code']] = item
def preprocess_loan_charges_data(data):
"""
Preprocesses the data into a dictionary for efficient lookups by 'code'.
Args:
data: A list of dictionaries.
Returns:
A dictionary where keys are 'code' values and values are the corresponding dictionaries from the input data.
If multiple items have the same code, the last one encountered will be stored.
"""
preprocessed = {}
for item in data:
if 'code' in item:
preprocessed[item['code']] = item
return preprocessed
+13 -13
View File
@@ -1,13 +1,13 @@
import logging
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[
# logging.StreamHandler(), # Log to console
logging.FileHandler("app.log", mode='a') # Log to file
]
)
logger = logging.getLogger("DetectionService")
import logging
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[
# logging.StreamHandler(), # Log to console
logging.FileHandler("app.log", mode='a') # Log to file
]
)
logger = logging.getLogger("DetectionService")
+39 -39
View File
@@ -1,40 +1,40 @@
from flask_mail import Message
from flask import current_app
from app.extensions import mail
import pandas as pd
from io import BytesIO
def get_report_data():
"""
Fetch and return loan summary data.
"""
return [
{"Type": "Disbursement", "Count": 45},
{"Type": "Repayment", "Count": 32},
]
def send_report_email(report_data: list, recipients: list):
"""
Sends an HTML + Excel report to the given email recipients.
"""
df = pd.DataFrame(report_data)
output = BytesIO()
df.to_excel(output, index=False)
output.seek(0)
html_table = df.to_html(index=False, border=1)
msg = Message(
subject="Loan Report Summary",
recipients=recipients,
html=f"<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)
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"
services:
flask:
build: .
env_file:
- .env
ports:
- "5000:5000"
environment:
- FLASK_APP=app.py
- FLASK_RUN_HOST=0.0.0.0
volumes:
- .:/app
restart: always
networks:
- digital
swagger:
image: swaggerapi/swagger-ui:v5.1.0
ports:
- "9000:8080"
volumes:
- ./openapi.yml:/usr/local/openapi.yml
environment:
- SWAGGER_JSON=/usr/local/openapi.yml
restart: always
networks:
- digital
networks:
digital:
driver: bridge
version: "3.8"
services:
flask:
build: .
env_file:
- .env
ports:
- "5000:5000"
environment:
- FLASK_APP=app.py
- FLASK_RUN_HOST=0.0.0.0
volumes:
- .:/app
restart: always
networks:
- digital
swagger:
image: swaggerapi/swagger-ui:v5.1.0
ports:
- "9000:8080"
volumes:
- ./openapi.yml:/usr/local/openapi.yml
environment:
- SWAGGER_JSON=/usr/local/openapi.yml
restart: always
networks:
- digital
networks:
digital:
driver: bridge
+264 -258
View File
@@ -1,259 +1,265 @@
openapi: 3.0.3
info:
title: Event Manager API
description: The documentation for Event Manager API
version: 1.0.0
contact:
name: API Support
email: support@example.com
license:
name: MIT
url: https://opensource.org/licenses/MIT
servers:
- url: http://localhost:5000
description: Local development server
- url: http://www.simbrellang.net:5000
description: Remote Temporary development server
- url: https://event-core.simbrellang.net
description: Remote development server
- url: http://10.2.249.133:5000
description: Internal development server
paths:
/health:
get:
summary: Returns a health message
responses:
200:
description: A successful response
/status-call:
post:
summary: Perform a status call
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
requestId:
type: string
example: "R02802"
countryCode:
type: string
example: "NGR"
transactionId:
type: string
example: "Tr201712RK9232P115"
debtId:
type: string
example: "173021"
transactionType:
type: string
example: "Disbursement"
customerId:
type: string
example: "CN621868"
responses:
200:
description: A successful response
/sms:
post:
summary: Send a SMS
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
text:
type: string
example: "This is a test message for SMS request method."
dest:
type: string
example: "+2348039409144"
unicode:
type: boolean
example: false
responses:
200:
description: A successful response
/bulk-sms:
post:
summary: Send a bulk SMS
requestBody:
required: true
content:
application/json:
schema:
type: array
items:
type: object
properties:
text:
type: string
example: "This is a test message for SMS request method."
dest:
type: string
example: "+2348039409144"
unicode:
type: boolean
example: true
responses:
200:
description: A successful response
/autocall/refresh-verify-disbursement:
get:
summary: Refresh the disbursement to verify
responses:
200:
description: A successful response
/autocall/refresh-disbursement:
get:
summary: Refresh the disbursement
responses:
200:
description: A successful response
/autocall/refresh-verify-collection:
get:
summary: Refresh the disbursement to verify
responses:
200:
description: A successful response
/autocall/refresh-collection:
get:
summary: Refresh the disbursement
responses:
200:
description: A successful response
/autocall/payment-callback:
get:
summary: The Payment callback
responses:
200:
description: A successful response
/autocall/penal-charge:
post:
summary: Penal Charge Request
requestBody:
required: true
content:
application/json:
schema:
type: array
items:
type: object
properties:
transactionId:
type: string
example: "T004"
fbnTransactionId:
type: string
example: "Tr201712RK9232P115"
debtId:
type: string
example: "273194670"
customerId:
type: string
example: "CN621868"
accountId:
type: string
example: "2017821799"
penalCharge:
type: number
example: "1.2"
lienAmount:
type: number
example: "101.2"
countryId:
type: string
example: "01"
comment:
type: string
example: "Testing PenalCharge"
responses:
200:
description: A successful response
/autocall/analytic-salary-detect:
post:
summary: Salary Detect Endpoint
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
customerId:
type: string
example: "CN621868"
accountId:
type: string
example: "OP621868"
status:
type: string
salaryAmount:
type: number
example: 200000
salaryDate:
type: string
example: "2025-01-01"
responses:
200:
description: A successful response
/autocall/report:
get:
summary: Generate and send a report
responses:
200:
description: A successful response
/autocall/overdue-loans:
get:
summary: Get all overdue loans
responses:
200:
description: A successful response
/autocall/process-penal-charges:
get:
summary: Get all overdue loans with grace period
responses:
200:
description: A successful response
/autocall/direct/loan:
post:
summary: Direct call for loan disbursement
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
transactionId:
type: string
example: "TXN123456"
responses:
200:
description: A successful response
/autocall/direct/repayment:
post:
summary: Direct call for loan repayment
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
transactionId:
type: string
example: "TXN123456"
responses:
200:
description: A successful response
openapi: 3.0.3
info:
title: Event Manager API
description: The documentation for Event Manager API
version: 1.0.0
contact:
name: API Support
email: support@example.com
license:
name: MIT
url: https://opensource.org/licenses/MIT
servers:
- url: http://localhost:5000
description: Local development server
- url: http://www.simbrellang.net:5000
description: Remote Temporary development server
- url: https://event-core.simbrellang.net
description: Remote development server
- url: http://10.2.249.133:5000
description: Internal development server
paths:
/health:
get:
summary: Returns a health message
responses:
200:
description: A successful response
/status-call:
post:
summary: Perform a status call
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
requestId:
type: string
example: "R02802"
countryCode:
type: string
example: "NGR"
transactionId:
type: string
example: "Tr201712RK9232P115"
debtId:
type: string
example: "173021"
transactionType:
type: string
example: "Disbursement"
customerId:
type: string
example: "CN621868"
responses:
200:
description: A successful response
/sms:
post:
summary: Send a SMS
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
text:
type: string
example: "This is a test message for SMS request method."
dest:
type: string
example: "+2348039409144"
unicode:
type: boolean
example: false
responses:
200:
description: A successful response
/bulk-sms:
post:
summary: Send a bulk SMS
requestBody:
required: true
content:
application/json:
schema:
type: array
items:
type: object
properties:
text:
type: string
example: "This is a test message for SMS request method."
dest:
type: string
example: "+2348039409144"
unicode:
type: boolean
example: true
responses:
200:
description: A successful response
/autocall/refresh-verify-disbursement:
get:
summary: Refresh the disbursement to verify
responses:
200:
description: A successful response
/autocall/retry-failed-disbursements:
get:
summary: Retry failed disbursements
responses:
200:
description: A successful response
/autocall/refresh-disbursement:
get:
summary: Refresh the disbursement
responses:
200:
description: A successful response
/autocall/refresh-verify-collection:
get:
summary: Refresh the disbursement to verify
responses:
200:
description: A successful response
/autocall/refresh-collection:
get:
summary: Refresh the disbursement
responses:
200:
description: A successful response
/autocall/payment-callback:
get:
summary: The Payment callback
responses:
200:
description: A successful response
/autocall/penal-charge:
post:
summary: Penal Charge Request
requestBody:
required: true
content:
application/json:
schema:
type: array
items:
type: object
properties:
transactionId:
type: string
example: "T004"
fbnTransactionId:
type: string
example: "Tr201712RK9232P115"
debtId:
type: string
example: "273194670"
customerId:
type: string
example: "CN621868"
accountId:
type: string
example: "2017821799"
penalCharge:
type: number
example: "1.2"
lienAmount:
type: number
example: "101.2"
countryId:
type: string
example: "01"
comment:
type: string
example: "Testing PenalCharge"
responses:
200:
description: A successful response
/autocall/analytic-salary-detect:
post:
summary: Salary Detect Endpoint
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
customerId:
type: string
example: "CN621868"
accountId:
type: string
example: "OP621868"
status:
type: string
salaryAmount:
type: number
example: 200000
salaryDate:
type: string
example: "2025-01-01"
responses:
200:
description: A successful response
/autocall/report:
get:
summary: Generate and send a report
responses:
200:
description: A successful response
/autocall/overdue-loans:
get:
summary: Get all overdue loans
responses:
200:
description: A successful response
/autocall/process-penal-charges:
get:
summary: Get all overdue loans with grace period
responses:
200:
description: A successful response
/autocall/direct/loan:
post:
summary: Direct call for loan disbursement
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
transactionId:
type: string
example: "TXN123456"
responses:
200:
description: A successful response
/autocall/direct/repayment:
post:
summary: Direct call for loan repayment
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
transactionId:
type: string
example: "TXN123456"
responses:
200:
description: A successful response
+16 -16
View File
@@ -1,16 +1,16 @@
# Flask and Extensions
Flask==2.3.3
Flask-Marshmallow==0.15.0
marshmallow==3.19.0
Flask-Cors==3.0.10
gunicorn
requests
confluent-kafka==1.9.2
flask-sqlalchemy
psycopg2-binary
alembic
python-dateutil
oracledb
Flask-Mail==0.10.0
pandas==2.1.3
openpyxl==3.1.5
# Flask and Extensions
Flask==2.3.3
Flask-Marshmallow==0.15.0
marshmallow==3.19.0
Flask-Cors==3.0.10
gunicorn
requests
confluent-kafka==1.9.2
flask-sqlalchemy
psycopg2-binary
alembic
python-dateutil
oracledb
Flask-Mail==0.10.0
pandas==2.1.3
openpyxl==3.1.5
+36 -36
View File
@@ -1,37 +1,37 @@
import threading
from app import create_app
from app.integrations import KafkaIntegration
from app.config import settings
from app.utils.logger import logger
app = create_app()
kafka = KafkaIntegration()
def start_kafka_consumer(app):
with app.app_context():
logger.info("Starting Kafka consumer...")
while True:
try:
message = kafka.receive_messages(
topics=settings.KAFKA_TOPICS, timeout=settings.KAFKA_TIMEOUT
)
if message:
logger.info(f"Processed message: {message}")
else:
logger.info("No message received within timeout")
except Exception as e:
logger.error(f"Error while receiving message: {e}")
if __name__ != "__main__":
# Expose WSGI app instance for Gunicorn
wsgi_app = app
# Start kafka in a thread
import threading
from app import create_app
from app.integrations import KafkaIntegration
from app.config import settings
from app.utils.logger import logger
app = create_app()
kafka = KafkaIntegration()
def start_kafka_consumer(app):
with app.app_context():
logger.info("Starting Kafka consumer...")
while True:
try:
message = kafka.receive_messages(
topics=settings.KAFKA_TOPICS, timeout=settings.KAFKA_TIMEOUT
)
if message:
logger.info(f"Processed message: {message}")
else:
logger.info("No message received within timeout")
except Exception as e:
logger.error(f"Error while receiving message: {e}")
if __name__ != "__main__":
# Expose WSGI app instance for Gunicorn
wsgi_app = app
# Start kafka in a thread
threading.Thread(target=start_kafka_consumer, args=(app,), daemon=True).start()