Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e27fb6d627 | |||
| c32c2502cc | |||
| f1db12c7f2 | |||
| 3032e6f0b9 | |||
| addb89af60 | |||
| 9386573dfd |
@@ -67,6 +67,14 @@ class Config:
|
||||
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)
|
||||
|
||||
@@ -5,4 +5,5 @@ class LoanStatus(str, Enum):
|
||||
ACTIVE = "active"
|
||||
ACTIVE_PARTIAL = "active_partial"
|
||||
START_REPAY = "start_repay"
|
||||
REPAID = "repaid"
|
||||
REPAID = "repaid"
|
||||
FAILED = "failed"
|
||||
@@ -137,6 +137,8 @@ class SimbrellaClient:
|
||||
logger.error("")
|
||||
LoanService.set_disbursement_loan_description(loan_data['debtId'],
|
||||
"Disbursement Service url not found (404)")
|
||||
logger.info(f"Loan status updated to {LoanStatus.FAILED} for loan ID {loan_data['debtId']} due to disbursement failure")
|
||||
LoanService.update_status(loan_data['debtId'], LoanStatus.FAILED)
|
||||
return ResponseHelper.error("Disbursement Service url not found (404)", status_code=404)
|
||||
|
||||
logger.info(f"Disbursement response: {response.json()}")
|
||||
@@ -165,6 +167,8 @@ class SimbrellaClient:
|
||||
errorMessage = "Unable to complete Disbursement Service with HTTP status code: " + str(
|
||||
response.status_code)
|
||||
LoanService.set_disbursement_loan_description(loan_data['debtId'], errorMessage)
|
||||
updatedLoan = LoanService.update_status(loan_data['debtId'], LoanStatus.FAILED)
|
||||
logger.info(f"Loan status updated to {updatedLoan.get('status')} for loan ID {loan_data['debtId']} due to disbursement failure")
|
||||
return ResponseHelper.error(errorMessage, status_code=response.status_code)
|
||||
|
||||
except requests.exceptions.HTTPError as errh:
|
||||
|
||||
+37
-1
@@ -9,6 +9,7 @@ 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"
|
||||
@@ -46,7 +47,6 @@ class Loan(db.Model):
|
||||
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)
|
||||
|
||||
@@ -102,6 +102,19 @@ class Loan(db.Model):
|
||||
@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):
|
||||
@@ -250,6 +263,29 @@ class Loan(db.Model):
|
||||
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):
|
||||
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
|
||||
@@ -122,21 +123,28 @@ class LoanRepaymentSchedule(db.Model):
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching active overdue repayment schedules: {e}")
|
||||
return []
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_overdue_repayment_schedule_with_grace_period(cls, grace_period_days, limit=None):
|
||||
"""
|
||||
Get all overdue repayment schedules that are not repaid and beyond the grace period.
|
||||
"""
|
||||
try:
|
||||
grace_period_date = datetime.now(timezone.utc) - timedelta(days=grace_period_days)
|
||||
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
|
||||
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):
|
||||
"""
|
||||
@@ -300,22 +308,26 @@ class LoanRepaymentSchedule(db.Model):
|
||||
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 = (schedule.penal_charge or 0) + penal_amount
|
||||
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):
|
||||
|
||||
|
||||
+110
-10
@@ -21,9 +21,17 @@ from app.config import settings
|
||||
|
||||
autocall_bp = Blueprint("autocall", __name__)
|
||||
|
||||
|
||||
#refresh-verify-disbursement
|
||||
#
|
||||
#
|
||||
@autocall_bp.route("/refresh-verify-disbursement", methods=["GET"])
|
||||
def verify_transaction():
|
||||
"""
|
||||
Get the latest loan with disbursement date and no verification date.
|
||||
Then call the verify transaction endpoint with the loan data.
|
||||
This is called after disbursement to ensure the loan was actually disbursed
|
||||
Then a verification date is set
|
||||
"""
|
||||
logger.info(f"Calling VerifyTransaction Components")
|
||||
|
||||
loan = LoanService.get_latest_loan_with_disburse_date()
|
||||
@@ -47,6 +55,11 @@ def verify_transaction():
|
||||
|
||||
@autocall_bp.route("/refresh-disbursement", methods=["GET"])
|
||||
def disbursement():
|
||||
"""
|
||||
Get the latest loan without disbursement date.
|
||||
Then call the disburse loan endpoint with the loan data.
|
||||
"""
|
||||
|
||||
# data = request.json()
|
||||
logger.info(f"Calling Disbursement Components")
|
||||
loan = LoanService.get_latest_loan_without_disburse_date()
|
||||
@@ -70,6 +83,14 @@ def disbursement():
|
||||
|
||||
@autocall_bp.route("/retry-disbursement", methods=["POST"])
|
||||
def retry_disbursement():
|
||||
"""
|
||||
This takes in a transaction id as input and
|
||||
retries the disbursement for the loan with that transaction id.
|
||||
This is to be used in cases where the disbursement failed due to network issues
|
||||
or other transient issues. It will call the disbursement endpoint
|
||||
with the loan data for the loan with the given transaction id.
|
||||
"""
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
logger.info(f"Retry Transaction ID Data Received for :::: {data}")
|
||||
@@ -101,12 +122,64 @@ def retry_disbursement():
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to call retry disbursement {data}: {e}")
|
||||
|
||||
@autocall_bp.route("/retry-failed-disbursements", methods=["GET"])
|
||||
def retry_failed_disbursements():
|
||||
"""
|
||||
This endpoint is for retrying failed disbursements.
|
||||
It will get the list of failed disbursements and call the disbursement endpoint for each of them.
|
||||
This is to be used in cases where the disbursement failed due to network issues or other transient issues. It will call the disbursement endpoint
|
||||
with the loan data for the loan with the given transaction id.
|
||||
"""
|
||||
|
||||
try:
|
||||
logger.info(f"Calling Retry Failed Disbursements Components")
|
||||
failed_disbursements = LoanService.get_failed_disbursements()
|
||||
if not failed_disbursements:
|
||||
logger.info(f"No failed disbursements found")
|
||||
return ResponseHelper.success(message="No failed disbursements found", status_code=200)
|
||||
|
||||
logger.info(f"Found {len(failed_disbursements)} failed disbursements to retry")
|
||||
#get batch size from settings
|
||||
# Safe config values
|
||||
batch_size = max(1, settings.FAILED_DISBURSEMENT_BATCH_SIZE or 1)
|
||||
delay_seconds = max(0, settings.FAILED_DISBURSEMENT_DELAY_SECONDS or 0)
|
||||
batch_delay = max(0, settings.FAILED_DISBURSEMENT_BATCH_DELAY_SECONDS or 0)
|
||||
for i in range(0, len(failed_disbursements), batch_size):
|
||||
batch = failed_disbursements[i:i + batch_size]
|
||||
for loan in batch:
|
||||
logger.info(f"Retrying disbursement for loan ID {loan.id}")
|
||||
loan_data = loan.to_dict()
|
||||
|
||||
data = {
|
||||
"transactionId": loan_data.get('transactionId'),
|
||||
"FbnTransactionId": loan_data.get('transactionId'),
|
||||
"debtId": str(loan_data.get('debtId')),
|
||||
"customerId": loan_data.get('customerId'),
|
||||
"accountId": loan_data.get('accountId'),
|
||||
"productId": str(loan_data.get('productId', "")),
|
||||
"provideAmount": loan_data.get('currentLoanAmount'),
|
||||
}
|
||||
response = SimbrellaClient.disburse_loan(data)
|
||||
logger.info(f"Retry Disbursement Result Received for loan ID {loan.id}: {response}")
|
||||
time_module.sleep(delay_seconds)
|
||||
time_module.sleep(batch_delay)
|
||||
return ResponseHelper.success(message="Retry Failed Disbursements Request Sent Successfully", status_code=200)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retry disbursements: {e}")
|
||||
return ResponseHelper.error("Failed to retry disbursements", status_code=500, error=str(e))
|
||||
|
||||
|
||||
|
||||
|
||||
@autocall_bp.route("/direct/loan", methods=["POST"])
|
||||
def direct_loan():
|
||||
"""
|
||||
This endpoint is for directly calling the disbursement endpoint with a transaction id.
|
||||
This is to be used in cases where the disbursement failed due to network issues
|
||||
or other transient issues. It will call the disbursement endpoint
|
||||
with the loan data for the loan with the given transaction id.
|
||||
"""
|
||||
|
||||
data = request.get_json()
|
||||
logger.info(f"Data received: {data}")
|
||||
|
||||
@@ -158,6 +231,10 @@ def direct_loan():
|
||||
|
||||
@autocall_bp.route("/direct/repayment", methods=["POST"])
|
||||
def direct_repayment():
|
||||
"""
|
||||
This endpoint is for directly calling the collect loan endpoint with a transaction id.
|
||||
"""
|
||||
|
||||
data = request.get_json()
|
||||
logger.info(f"Data received: {data}")
|
||||
|
||||
@@ -238,12 +315,12 @@ def direct_repayment():
|
||||
|
||||
response = SimbrellaClient.collect_loan_user_initiated(data_to_process)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
|
||||
@autocall_bp.route("/refresh-verify-collection", methods=["GET"])
|
||||
def refresh_verify_collection():
|
||||
"""
|
||||
This endpoint is for directly calling the verify collection endpoint.
|
||||
"""
|
||||
data = request.get_json()
|
||||
logger.info(f"Calling Verify Collection")
|
||||
|
||||
@@ -253,6 +330,11 @@ def refresh_verify_collection():
|
||||
|
||||
@autocall_bp.route("/refresh-collection", methods=["GET"])
|
||||
def refresh_collection():
|
||||
"""
|
||||
This endpoint is for directly calling the collect loan endpoint.
|
||||
It will get the latest repayment with no repay date and call the collect loan endpoint with that repayment data.
|
||||
This is to be used in cases where the collection failed due to network issues or other transient issues. It will call the collection endpoint
|
||||
"""
|
||||
#data = request.get_json()
|
||||
logger.info(f"Calling Collection ")
|
||||
#grab the last repayments with repay date is none
|
||||
@@ -288,7 +370,11 @@ def payment_callback():
|
||||
return response
|
||||
|
||||
@autocall_bp.route("/penal-charge", methods=["POST"])
|
||||
|
||||
def penal_charge():
|
||||
"""
|
||||
This endpoint is for directly calling the penal charge endpoint.
|
||||
"""
|
||||
data = request.get_json()
|
||||
logger.info(f"Calling Penal Charge Endpoint")
|
||||
|
||||
@@ -299,9 +385,15 @@ def penal_charge():
|
||||
logger.error(f"Error in Penal Charge: {e}")
|
||||
return ResponseHelper.error("Penal charge failed")
|
||||
|
||||
|
||||
@autocall_bp.route("/analytic-salary-detect", methods=["POST"])
|
||||
def salary_detect():
|
||||
"""
|
||||
Creates new salary data
|
||||
Gets the salary list not yet processed
|
||||
then, gets the loan list for each salary and
|
||||
creates repayments for each loan with the salary data,
|
||||
then calls the collect loan endpoint for each repayment created.
|
||||
"""
|
||||
payload = request.get_json()
|
||||
logger.info("Calling Salary Detect endpoint")
|
||||
|
||||
@@ -327,13 +419,11 @@ def salary_detect():
|
||||
logger.info("Finished processing List")
|
||||
return ResponseHelper.success([], "AutoCall Add Salary Successful")
|
||||
|
||||
|
||||
@autocall_bp.route("/analytic-salary-process", methods=["POST"])
|
||||
def salary_process():
|
||||
response = process_salary_list()
|
||||
return ResponseHelper.success([], "AutoCall Successful")
|
||||
|
||||
|
||||
def process_salary_list():
|
||||
# Step 1: Get all pending salaries
|
||||
pending_salaries = SalaryService.get_pending_salaries()
|
||||
@@ -427,10 +517,9 @@ def process_salary_list():
|
||||
|
||||
return ResponseHelper.success([], "Processed all pending salaries")
|
||||
|
||||
|
||||
|
||||
@autocall_bp.route("/report", methods=["GET"])
|
||||
def report():
|
||||
"""This endpoint is for generating the report and sending the email."""
|
||||
try:
|
||||
report_data = get_report_data()
|
||||
logger.info(f"Generated report data: {report_data}")
|
||||
@@ -445,6 +534,12 @@ def report():
|
||||
|
||||
@autocall_bp.route("/process-penal-charges", methods=["GET"])
|
||||
def process_penal_charges():
|
||||
"""
|
||||
This endpoint is for processing penal charges for overdue loans schedule with grace period.
|
||||
It will check for overdue loans, calculate the penal charge,
|
||||
create a new penal charge record, update the loan and repayment schedule with the new penal charge, and call the Simbrella endpoint to update the penal charge on their system.
|
||||
"""
|
||||
|
||||
try:
|
||||
OVERDUE_GRACE_PERIOD_DAYS = settings.OVERDUE_GRACE_PERIOD_DAYS
|
||||
OVERDUE_PROCESSING_LIST_LIMIT = settings.OVERDUE_PROCESSING_LIST_LIMIT
|
||||
@@ -559,6 +654,12 @@ def process_penal_charges():
|
||||
|
||||
@autocall_bp.route("/overdue-loans", methods=["GET"])
|
||||
def overdue_loans():
|
||||
"""
|
||||
This endpoint is for processing overdue loans.
|
||||
It will get all active overdue loan schedules,
|
||||
and then for each loan schedule, it will create a repayment, update the loan status, and call the Simbrella endpoint to collect the loan.
|
||||
"""
|
||||
|
||||
try:
|
||||
# Step 1: Get all active overdue loans
|
||||
overdue_loans = LoanRepaymentScheduleService.get_active_overdue_repayment_schedule()
|
||||
@@ -596,7 +697,6 @@ def overdue_loans():
|
||||
logger.exception(f"Error fetching overdue loans: {e}")
|
||||
return ResponseHelper.error("Failed to fetch overdue loans", status_code=500, error=str(e))
|
||||
|
||||
|
||||
def process_overdue_loan(loan):
|
||||
"""
|
||||
Handles repayment creation, loan status update, and Simbrella call
|
||||
|
||||
@@ -59,6 +59,21 @@ class LoanService:
|
||||
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
|
||||
|
||||
@@ -110,6 +110,12 @@ paths:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user