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
8 changed files with 203 additions and 21 deletions
+8
View File
@@ -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)
+2 -1
View File
@@ -5,4 +5,5 @@ class LoanStatus(str, Enum):
ACTIVE = "active"
ACTIVE_PARTIAL = "active_partial"
START_REPAY = "start_repay"
REPAID = "repaid"
REPAID = "repaid"
FAILED = "failed"
+4
View File
@@ -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
View File
@@ -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):
+21 -9
View File
@@ -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
View File
@@ -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
+15
View File
@@ -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
+6
View File
@@ -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