26 Commits

Author SHA1 Message Date
Chinenye Nmoh 93d2659462 added extra query 2026-03-17 09:42:18 +01:00
Chinenye Nmoh 569d4c45d7 added extra query 2026-03-17 09:37:08 +01:00
ameye bfb35e0285 Merge branch 'test' of DigiFi/digifi-EventManager into master 2026-03-12 12:56:50 +00:00
Chinenye Nmoh 1a0e1f324a added penal charge to schedule 2026-03-12 12:50:25 +01:00
CHIEFSOFT\ameye 6593aedc56 PENAL_CHARGE_MAXIMUM_COUNT 2026-03-11 06:43:43 -04:00
CHIEFSOFT\ameye 818f968935 PENAL_CHARGE_INTERVAL_DAYS 2026-03-11 06:22:53 -04:00
CHIEFSOFT\ameye 84648d8242 New penal charge columns 2026-03-11 06:12:07 -04:00
ameye 80a41d5ee1 Merge branch 'test' of DigiFi/digifi-EventManager into master 2026-03-11 09:46:57 +00:00
Chinenye Nmoh cf3a96ad98 added penal charge log 2026-03-11 10:25:03 +01:00
ameye d0dccbf1ec Merge branch 'test' of DigiFi/digifi-EventManager into master 2026-01-30 09:09:20 +00:00
Chinenye Nmoh 5c8ffc5bbc corrected interest charges on 3 months loan and made loan schedule active by default 2026-01-29 22:11:59 +01:00
ameye 9d7c3cfb32 Merge branch 'test' of DigiFi/digifi-EventManager into master 2026-01-24 01:21:50 +00:00
Chinenye Nmoh f6f8e369c4 added logic to only send charges for 1 month loan 2026-01-23 20:42:03 +01:00
Chinenye Nmoh 39ea231fa0 added logic to only send charges for 1 month loan 2026-01-23 20:38:53 +01:00
Chinenye Nmoh da05ba0f3d Merge branch 'master' of https://gitlab.chiefsoft.net/DigiFi/digifi-EventManager into test
pulled changes from main
2026-01-23 19:12:24 +01:00
Chinenye Nmoh 0f4455e738 added a new penal charge endpoint 2026-01-23 11:14:17 +01:00
ameye 53a843d129 Merge branch 'test' of DigiFi/digifi-EventManager into master 2026-01-23 01:04:23 +00:00
Chinenye Nmoh 03c12fd9b5 added a new penal charge endpoint 2026-01-22 22:41:06 +01:00
ameye a338441086 Merge branch 'test' of DigiFi/digifi-EventManager into master 2026-01-20 15:29:13 +00:00
Chinenye Nmoh f048dd99ba added throttling and batch size to processing overdue loans 2026-01-20 14:03:39 +01:00
ameye 46b3c856b1 Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-11-28 10:25:48 +00:00
Chinenye Nmoh 1cbb55fae6 added overdue loan methods 2025-11-24 16:52:43 +01:00
ameye 38dbb32579 Merge branch 'test' of DigiFi/digifi-EventManager into master 2025-11-21 02:55:31 +00:00
Chinenye Nmoh af14baead5 Merge branch 'master' of https://gitlab.chiefsoft.net/DigiFi/digifi-EventManager into test
pulled master
2025-11-20 10:28:54 +01:00
Chinenye Nmoh fbc3f090a0 added logs 2025-11-20 10:23:12 +01:00
ameye 675c38462e Merge branch 'fixed_sms_destination' of DigiFi/digifi-EventManager into master 2025-11-14 09:42:10 +00:00
49 changed files with 4520 additions and 4201 deletions
+14
View File
@@ -55,6 +55,20 @@ class Config:
MAIL_DEFAULT_SENDER = ('FirstAdvance', 'firstadvance@dynamikservices.tech') MAIL_DEFAULT_SENDER = ('FirstAdvance', 'firstadvance@dynamikservices.tech')
MAIL_RECEIVER= os.getenv('MAIL_RECEIVER', 'chinenyeumeaku@gmail.com,umeakuchinenye@gmail.com') 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_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_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_SMS_BASE_URL= os.getenv("BANK_CALL_SMS_BASE_URL","https://first-advance-middleware-develop.fbn-devops-dev-asenv.appserviceenvironment.net/SMS")
+2
View File
@@ -3,3 +3,5 @@ from enum import Enum
class RepaymentScheduleStatus(str, Enum): class RepaymentScheduleStatus(str, Enum):
PARTIALLY_PAID = "partially_paid" PARTIALLY_PAID = "partially_paid"
REPAID = "repaid" REPAID = "repaid"
ACTIVE = "active"
OVERDUE = "overdue"
+6
View File
@@ -54,4 +54,10 @@ class CollectLoanHelper:
"comment": "COLLECT LOAN" "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]
+13 -3
View File
@@ -92,9 +92,8 @@ class SimbrellaClient:
vat_fee = loan_charges.get("VAT")['amount'] vat_fee = loan_charges.get("VAT")['amount']
interest_fee = loan_charges.get("INTEREST")['amount'] interest_fee = loan_charges.get("INTEREST")['amount']
insurance_fee = loan_charges.get("INSURANCE")['amount'] insurance_fee = loan_charges.get("INSURANCE")['amount']
product_id = str(loan_data.get('productId', ""))
debtId = str(loan_data.get('debtId', "")).strip().zfill(6) debtId = str(loan_data.get('debtId', "")).strip().zfill(6)
disbursement_data = { disbursement_data = {
"transactionId": loan_data.get('transactionId'), "transactionId": loan_data.get('transactionId'),
"fbnTransactionId": loan_data.get('transactionId'), "fbnTransactionId": loan_data.get('transactionId'),
@@ -103,7 +102,7 @@ class SimbrellaClient:
"accountId": loan_data.get('accountId'), "accountId": loan_data.get('accountId'),
"productId": str(loan_data.get('productId', "")), "productId": str(loan_data.get('productId', "")),
"provideAmount": loan_data.get('currentLoanAmount'), "provideAmount": loan_data.get('currentLoanAmount'),
"collectAmountInterest": interest_fee, "collectAmountInterest": interest_fee if product_id != '3MPC' else 0,
"collectAmountMgtFee": mgt_fee, "collectAmountMgtFee": mgt_fee,
"collectAmountInsurance": insurance_fee, "collectAmountInsurance": insurance_fee,
"collectAmountVAT": vat_fee, "collectAmountVAT": vat_fee,
@@ -148,7 +147,17 @@ class SimbrellaClient:
result.get('responseMessage', '')) result.get('responseMessage', ''))
reload_loan = LoanService.get_loan_by_transaction_id(transaction_id=data['transactionId']) reload_loan = LoanService.get_loan_by_transaction_id(transaction_id=data['transactionId'])
reload_loan_data = reload_loan.to_dict() reload_loan_data = reload_loan.to_dict()
#mark repayment schedule as active
repayment_schedule = LoanRepaymentScheduleService.get_repayment_schedule_by_loan_id(
reload_loan_data['debtId'], include_paid=False)
logger.info(f'Loan repayment schedule: {repayment_schedule}')
if repayment_schedule:
for schedule in repayment_schedule:
logger.info(f"Updating repayment schedule ID {schedule.id} status to ACTIVE")
LoanRepaymentScheduleService.update_repayment_schedule_status_to_active(schedule.id)
SimbrellaClient.verify_disbursement_transaction(reload_loan_data) SimbrellaClient.verify_disbursement_transaction(reload_loan_data)
return ResponseHelper.success(response.json(), "Successful") return ResponseHelper.success(response.json(), "Successful")
else: else:
@@ -279,6 +288,7 @@ class SimbrellaClient:
logger.error("Received 404 from external service") logger.error("Received 404 from external service")
return ResponseHelper.error("Verify Service url not found (404)", status_code=404) return ResponseHelper.error("Verify Service url not found (404)", status_code=404)
result = response.json() result = response.json()
#check for res 00 and status 200
logger.info(f"this is verify result, {result}") logger.info(f"this is verify result, {result}")
LoanService.set_disburse_verify_result(loan_data['debtId'], result.get('responseCode', ''), LoanService.set_disburse_verify_result(loan_data['debtId'], result.get('responseCode', ''),
result.get('responseMessage', '')) result.get('responseMessage', ''))
+28 -3
View File
@@ -47,6 +47,9 @@ class Loan(db.Model):
verify_description = db.Column(db.String(100), nullable=True) verify_description = db.Column(db.String(100), nullable=True)
reference = db.Column(db.String(50), nullable=True) reference = db.Column(db.String(50), nullable=True)
total_penal_charge = db.Column(db.Float, default=0.0)
last_penal_date = db.Column(db.DateTime, nullable=True)
customer = relationship( customer = relationship(
"Customer", "Customer",
primaryjoin="Customer.id == Loan.customer_id", primaryjoin="Customer.id == Loan.customer_id",
@@ -92,6 +95,8 @@ class Loan(db.Model):
'reference': self.reference, 'reference': self.reference,
'balance': self.balance, 'balance': self.balance,
'tenor': self.tenor, 'tenor': self.tenor,
'totalPenalCharge': self.total_penal_charge,
'lastPenalDate': self.last_penal_date
} }
@classmethod @classmethod
@@ -236,9 +241,15 @@ class Loan(db.Model):
""" """
Get the latest loan without a disbursement date. Get the latest loan without a disbursement date.
""" """
return cls.query.filter( logger.info("Fetching latest loan without disburse date")
cls.disburse_date.is_(None)
).order_by(cls.created_at.desc()).first() 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 @classmethod
def get_latest_loan_with_disburse_date(cls): def get_latest_loan_with_disburse_date(cls):
@@ -388,3 +399,17 @@ class Loan(db.Model):
except Exception as e: except Exception as e:
logger.error(f"Error fetching overdue loans: {e}") logger.error(f"Error fetching overdue loans: {e}")
return [] 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()
+79 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from os.path import devnull from os.path import devnull
from sqlalchemy.exc import IntegrityError
from app.extensions import db from app.extensions import db
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@@ -43,7 +43,85 @@ class LoanCharge(db.Model):
'description': self.description, 'description': self.description,
'due': self.due '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 @classmethod
def get_loan_charge_by_debt_id(cls, debt_id): def get_loan_charge_by_debt_id(cls, debt_id):
return cls.query.filter_by(loan_id=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
+126 -4
View File
@@ -1,8 +1,10 @@
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from app.extensions import db from app.extensions import db
from app.utils.logger import logger from app.utils.logger import logger
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy import or_
from app.enums.repayment_schedule_status import RepaymentScheduleStatus from app.enums.repayment_schedule_status import RepaymentScheduleStatus
from app.config import settings
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
# from dateutil.relativedelta import relativedelta # from dateutil.relativedelta import relativedelta
@@ -27,6 +29,9 @@ class LoanRepaymentSchedule(db.Model):
partial_balance = db.Column(db.Float, default=0.0) partial_balance = db.Column(db.Float, default=0.0)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc)) created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc)) updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
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)
@@ -48,7 +53,11 @@ class LoanRepaymentSchedule(db.Model):
'partial_balance': self.partial_balance, 'partial_balance': self.partial_balance,
'paid_at': self.paid_at.isoformat() if self.paid_at else None, 'paid_at': self.paid_at.isoformat() if self.paid_at else None,
'created_at': self.created_at.isoformat() if self.created_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 '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): def __repr__(self):
@@ -89,13 +98,72 @@ class LoanRepaymentSchedule(db.Model):
@classmethod @classmethod
def get_overdue_repayment_schedule(cls): def get_overdue_repayment_schedule(cls):
""" """
Get all overdue repayment schedules. Get all overdue repayment schedules that are not repaid.
""" """
try: try:
return cls.query.filter(cls.due_date < datetime.now(timezone.utc), cls.paid == False).all() 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: except Exception as e:
logger.error(f"Error fetching overdue repayment schedules: {e}") logger.error(f"Error fetching overdue repayment schedules: {e}")
return [] 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 @classmethod
def get_repayment_schedule_by_transaction_id(cls, transaction_id): def get_repayment_schedule_by_transaction_id(cls, transaction_id):
@@ -163,6 +231,24 @@ class LoanRepaymentSchedule(db.Model):
logger.error(f"Error updating repayment schedule {schedule_id} after loan repayment: {e}") logger.error(f"Error updating repayment schedule {schedule_id} after loan repayment: {e}")
raise 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 @classmethod
def update_repayment_schedule_balance(cls, schedule_id, amount_collected): def update_repayment_schedule_balance(cls, schedule_id, amount_collected):
@@ -222,5 +308,41 @@ class LoanRepaymentSchedule(db.Model):
db.session.rollback() db.session.rollback()
logger.error(f"Error applying repayment for schedule {schedule_id}: {e}") logger.error(f"Error applying repayment for schedule {schedule_id}: {e}")
raise 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
+148 -134
View File
@@ -1,8 +1,10 @@
import time as time_module
from flask import Blueprint, request, jsonify, current_app from flask import Blueprint, request, jsonify, current_app
import requests import requests
from app.extensions import db from app.extensions import db
from app.config import settings from app.config import settings
from app.helpers.response_helper import ResponseHelper from app.helpers.response_helper import ResponseHelper
from app.helpers.collect_loan_helper import CollectLoanHelper
from app.utils.auth import get_headers from app.utils.auth import get_headers
from app.utils.logger import logger from app.utils.logger import logger
from app.integrations.simbrella import SimbrellaClient from app.integrations.simbrella import SimbrellaClient
@@ -10,9 +12,11 @@ from app.services.loan import LoanService
from app.services.repayment import RepaymentService from app.services.repayment import RepaymentService
from app.services.salary import SalaryService from app.services.salary import SalaryService
from app.services.loan_repayment_schedule import LoanRepaymentScheduleService from app.services.loan_repayment_schedule import LoanRepaymentScheduleService
from app.services.loan_charge import LoanChargesService
from app.enums.loan_status import LoanStatus from app.enums.loan_status import LoanStatus
from app.enums.repayment_schedule_status import RepaymentScheduleStatus from app.enums.repayment_schedule_status import RepaymentScheduleStatus
from app.utils.mail import send_report_email, get_report_data from app.utils.mail import send_report_email, get_report_data
from datetime import datetime, timezone, timedelta
from app.config import settings from app.config import settings
autocall_bp = Blueprint("autocall", __name__) autocall_bp = Blueprint("autocall", __name__)
@@ -98,46 +102,7 @@ def retry_disbursement():
logger.error(f"Failed to call retry disbursement {data}: {e}") logger.error(f"Failed to call retry disbursement {data}: {e}")
@autocall_bp.route("/verify-disbursement", methods=["POST"])
def retry_verify_disbursement():
try:
data = request.get_json()
logger.info(f"Verify Disbursement Transaction ID Data Received for :::: {data}")
transactionId = data["transactionId"]
logger.info(f"Starting Transaction ID Data Received for :::: {transactionId}")
logger.info(f"Calling Disbursement Components for Retry Transaction ID Data Received for :::: {transactionId}")
loan = LoanService.get_loan_by_transaction_id(transactionId)
if not loan:
logger.info(f"No loan found without disbursement date")
return 0
logger.info(f"Calling DisburseLoan endpoint with data: {loan}")
loan_data = loan.to_dict()
data = {
"transactionId": loan_data.get('transactionId'),
"fbnTransactionId": loan_data.get('transactionId'),
"debtId": str(loan_data.get('debtId')),
"customerId": loan_data.get('customerId'),
"accountId": loan_data.get('accountId'),
"productId": str(loan_data.get('productId', "")),
"provideAmount": loan_data.get('currentLoanAmount'),
}
response = SimbrellaClient.verify_transaction(data)
return ResponseHelper.success(message="Retry Verify Disbursement Request Sent Successfully", status_code=200)
except Exception as e:
logger.error(f"Failed to call retry disbursement {data}: {e}")
@autocall_bp.route("/start-repayment", methods=["POST"])
def start_repayment_office():
try:
data = request.get_json()
logger.info(f"Start Repay Transaction ID Data Received for :::: {data}")
loan_repayment_function(data, 'OFFICE')
return ResponseHelper.success(message="Retry Disbursement Request Sent Successfully", status_code=200)
except Exception as e:
logger.error(f"Failed to call retry disbursement {data}: {e}")
@autocall_bp.route("/direct/loan", methods=["POST"]) @autocall_bp.route("/direct/loan", methods=["POST"])
@@ -164,18 +129,12 @@ def direct_loan():
loan = LoanService.get_loan_by_transaction_id(transaction_id=transaction_id) loan = LoanService.get_loan_by_transaction_id(transaction_id=transaction_id)
if not loan: if not loan:
first_error = f"Loan with transaction id {transaction_id} does not exist" logger.warning(f"Loan with transaction id {transaction_id} does not exist")
logger.warning(first_error) return jsonify({
loan = LoanService.get_latest_loan_without_disburse_date() "status": "error",
if not loan: "message": f"Loan with transaction id {transaction_id} does not exist"
logger.info(f"No loan found without disbursement date") }), 400
return jsonify({
"status": "error",
"first_error": first_error,
"message": f"No loan found without disbursement date"
}), 400
# Tried 2 method to get the loan record at this point
loan_data = loan.to_dict() loan_data = loan.to_dict()
# Prevent double disbursement # Prevent double disbursement
@@ -281,85 +240,6 @@ def direct_repayment():
return response return response
def loan_repayment_function(data, initiatedBy):
logger.info(f"Data Received Loan Repayment: {data} By {initiatedBy}")
REQUIRED_KEYS = ["transactionId"]
# Check for missing keys
missing_keys = [key for key in REQUIRED_KEYS if key not in data or data[key] is None]
if missing_keys:
logger.warning(f"Missing required keys: {missing_keys}")
return jsonify({
"status": "error",
"message": f"Missing required fields: {', '.join(missing_keys)}"
}), 400
# Check if the loan exists
logger.info(f"Checking if loan with transaction id {data['transactionId']} exists")
loan = LoanService.get_loan_by_transaction_id(transaction_id=data['transactionId'])
if not loan:
logger.info(f"Loan with transaction id {data['transactionId']} does not exist")
return jsonify({
"status": "error",
"message": f"Loan with transaction id {data['transactionId']} does not exist"
}), 400
loan_data = loan.to_dict()
# check if loan has been repaid
if loan_data.get("status") == LoanStatus.REPAID and loan_data.get("balance") <= 0:
logger.info(f"Loan with Id {loan_data.get('debtId')} has been repaid")
return jsonify({
"status": "error",
"message": f"loan with Id {loan_data.get('debtId')} has been repaid"
}), 400
repayment_data = {
"customerId": loan_data.get("customerId"),
"loanId": loan_data.get("debtId"),
"productId": loan_data.get("productId"),
"transactionId": loan_data.get("transactionId"),
"initiatedBy": initiatedBy,
"salaryAmount": 0,
"LoanStatus": loan_data.get("status"),
}
logger.info(f"Creating repayment with data: {repayment_data}")
try:
repayment = RepaymentService.create_repayment(repayment_data)
logger.info(f"Repayment created: {repayment}")
except Exception as e:
db.session.rollback()
logger.error(f"Repayment creation raised exception: {e}")
return jsonify({
"status": "error",
"message": "Failed to create repayment"
}), 500
if not repayment or (isinstance(repayment, dict) and "error" in repayment):
db.session.rollback()
logger.error(f"Repayment creation failed for loan ID {loan_data.get('debtId')}: {repayment}")
try:
if loan_data.get('status') == LoanStatus.ACTIVE:
LoanService.update_status(loan_id=loan_data.get('debtId'), status=LoanStatus.START_REPAY)
except Exception as e:
db.session.rollback()
logger.error(f"Failed to update loan status for loan ID {loan_data.get('debtId')}: {e}")
repayment_data_dict = repayment.to_dict()
data_to_process = {
"transactionId": repayment_data_dict['transactionId'],
"debtId": repayment_data_dict['loanId'],
"customerId": repayment_data_dict['customerId'],
"productId": repayment_data_dict['productId'],
"Id": repayment_data_dict['Id']
}
response = SimbrellaClient.collect_loan_user_initiated(data_to_process)
return response
@autocall_bp.route("/refresh-verify-collection", methods=["GET"]) @autocall_bp.route("/refresh-verify-collection", methods=["GET"])
@@ -563,25 +443,157 @@ def report():
logger.error(f"Error generating or sending report: {e}") logger.error(f"Error generating or sending report: {e}")
return ResponseHelper.error("Failed to send report", status_code=500, error=str(e)) return ResponseHelper.error("Failed to send report", status_code=500, error=str(e))
@autocall_bp.route("/process-penal-charges", methods=["GET"])
def process_penal_charges():
try:
OVERDUE_GRACE_PERIOD_DAYS = settings.OVERDUE_GRACE_PERIOD_DAYS
OVERDUE_PROCESSING_LIST_LIMIT = settings.OVERDUE_PROCESSING_LIST_LIMIT
PENAL_CHARGE_MAXIMUM_COUNT = settings.PENAL_CHARGE_MAXIMUM_COUNT
PENAL_CHARGE_INTERVAL_DAYS = settings.PENAL_CHARGE_INTERVAL_DAYS
now = datetime.now(timezone.utc)
overdue_schedules = (
LoanRepaymentScheduleService
.get_overdue_repayment_schedule_with_grace_period(
OVERDUE_GRACE_PERIOD_DAYS,
OVERDUE_PROCESSING_LIST_LIMIT
)
)
logger.info(f"Found {len(overdue_schedules)} overdue loan schedule.")
if not overdue_schedules:
return ResponseHelper.success(
message="No overdue loan schedule found",
status_code=200
)
processed_loans = []
for schedule in overdue_schedules:
loan = LoanService.get_loan_by_loan_id(schedule.loan_id)
if not loan:
logger.info(f"Loan with id {schedule.loan_id} not found")
continue
penal_count = schedule.penal_count or 0
# MAX PENAL CHECK
if penal_count >= PENAL_CHARGE_MAXIMUM_COUNT:
logger.info(
f"Penal count for schedule {schedule.id} has reached the maximum limit."
)
continue
# INTERVAL CHECK (PER SCHEDULE)
if schedule.last_penal_date:
# ensure last_penal_date is timezone-aware
last_penal = schedule.last_penal_date
if last_penal.tzinfo is None:
last_penal = last_penal.replace(tzinfo=timezone.utc)
next_allowed_date = last_penal + timedelta(days=PENAL_CHARGE_INTERVAL_DAYS)
if now < next_allowed_date:
logger.info(
f"Penal interval for schedule {schedule.id} has not passed yet."
)
continue
# NEXT PENAL NUMBER
next_penal_no = penal_count + 1
# CALCULATE PENAL
penal_amount = LoanRepaymentScheduleService.calculate_penal_charge(schedule)
# CREATE PENAL CHARGE
new_penal_charge = LoanChargesService.create_penal_charges_for_loan(
loan_id=schedule.loan_id,
transaction_id=schedule.transaction_id,
percent=settings.PENAL_CHARGE_PERCENTAGE,
penal_no=next_penal_no,
schedule_number=schedule.installment_number,
penal_amount=penal_amount
)
if not new_penal_charge:
logger.error(f"Failed to create penal charge for loan ID: {loan.id}")
continue
logger.info(f"Penal charge created: {new_penal_charge.to_dict()}")
# UPDATE SCHEDULE
LoanRepaymentScheduleService.apply_penal_to_schedule(
schedule.id,
penal_amount
)
logger.info(f"Penal charge applied to schedule {schedule.id}")
# UPDATE LOAN TOTAL
LoanService.apply_penal_to_loan(
loan.id,
penal_amount
)
logger.info(f"Penal charge applied to loan {loan.id}")
processed_loans.append(loan.to_dict())
return ResponseHelper.success(
message="Penal Charges Processed Successfully",
status_code=200,
data=processed_loans
)
except Exception as e:
logger.exception(f"Error processing penal charges: {e}")
return ResponseHelper.error(
"Failed to process penal charges",
status_code=500,
error=str(e)
)
@autocall_bp.route("/overdue-loans", methods=["GET"]) @autocall_bp.route("/overdue-loans", methods=["GET"])
def overdue_loans(): def overdue_loans():
try: try:
# Step 1: Get all overdue loans # Step 1: Get all active overdue loans
overdue_loans = LoanRepaymentScheduleService.get_overdue_repayment_schedule() overdue_loans = LoanRepaymentScheduleService.get_active_overdue_repayment_schedule()
logger.info(f"Found {len(overdue_loans)} overdue loans.") logger.info(f"Found {len(overdue_loans)} overdue loans.")
if not overdue_loans: if not overdue_loans:
logger.info("No overdue loans found.")
return ResponseHelper.success(message="No overdue loans found", status_code=200) return ResponseHelper.success(message="No overdue loans found", status_code=200)
#get batch size from settings
loan_delay_seconds = max(0, settings.OVERDUE_LOAN_DELAY_SECONDS)
batch_delay_seconds = max(0, settings.OVERDUE_LOAN_BATCH_DELAY_SECONDS)
batch_size = max(1, settings.OVERDUE_LOAN_BATCH_SIZE)
loan_chunks = list(CollectLoanHelper.chunk_list(overdue_loans, batch_size))
logger.info(f"Found {len(loan_chunks)} loan chunks to process.")
# Step 2: Process each loan # Step 2: Process each loan
for loan in overdue_loans: for chunk_index, loan_chunk in enumerate(loan_chunks):
process_overdue_loan(loan) logger.info(f"Processing chunk {chunk_index + 1} of {len(loan_chunks)} with {len(loan_chunk)} loans.")
for loan in loan_chunk:
try:
process_overdue_loan(loan)
except Exception:
logger.exception(f"Failed processing loan {loan.id}")
finally:
time_module.sleep(loan_delay_seconds)
if chunk_index < len(loan_chunks) - 1:
logger.info(f"Waiting {batch_delay_seconds} seconds before processing next chunk...")
time_module.sleep(batch_delay_seconds) # Delay between chunks
return ResponseHelper.success(message="Processed overdue loans successfully", status_code=200) return ResponseHelper.success(message="Processed overdue loans successfully", status_code=200)
except Exception as e: except Exception as e:
logger.error(f"Error fetching overdue loans: {e}") logger.exception(f"Error fetching overdue loans: {e}")
return ResponseHelper.error("Failed to fetch overdue loans", status_code=500, error=str(e)) return ResponseHelper.error("Failed to fetch overdue loans", status_code=500, error=str(e))
@@ -630,6 +642,7 @@ def process_overdue_loan(loan):
# Update loan status # Update loan status
try: try:
logger.info(f"Updating loan status for loan ID {loan.loan_id}")
LoanService.update_status(loan_id=loan.loan_id, status=LoanStatus.START_REPAY) LoanService.update_status(loan_id=loan.loan_id, status=LoanStatus.START_REPAY)
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
@@ -647,6 +660,7 @@ def process_overdue_loan(loan):
repayment_data["overdueLoanScheduleAmount"] = amount repayment_data["overdueLoanScheduleAmount"] = amount
repayment_data["overdueLoanScheduleId"] = loan.id repayment_data["overdueLoanScheduleId"] = loan.id
repayment_data["Id"] = repayment.id repayment_data["Id"] = repayment.id
logger.info(f"Calling Simbrella for with repayment data: {repayment_data}")
simbrella_response = SimbrellaClient.collect_loan_user_due_payment(repayment_data) simbrella_response = SimbrellaClient.collect_loan_user_due_payment(repayment_data)
if isinstance(simbrella_response, tuple): if isinstance(simbrella_response, tuple):
+4 -1
View File
@@ -58,7 +58,7 @@ class LoanService:
@classmethod @classmethod
def set_disbursement_loan_description(cls,loan_id,description): def set_disbursement_loan_description(cls,loan_id,description):
return Loan.set_disbursement_message(loan_id, result, description) return Loan.set_disbursement_message(loan_id, description)
@classmethod @classmethod
@@ -117,6 +117,9 @@ class LoanService:
Get all overdue loans. Get all overdue loans.
""" """
return Loan.get_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 @staticmethod
def _update_loan_after_collection(loan, loan_data, updated_loan, amount_collected, data, response_message): def _update_loan_after_collection(loan, loan_data, updated_loan, amount_collected, data, response_message):
if loan.balance is None or loan.balance <= 0: if loan.balance is None or loan.balance <= 0:
+13
View File
@@ -0,0 +1,13 @@
from app.models.loan_charge import LoanCharge
class LoanChargesService:
@classmethod
def create_penal_charges_for_loan(cls, loan_id, transaction_id, percent, penal_no, schedule_number, penal_amount=0.0,):
return LoanCharge.create_penal_charges_for_loan(loan_id, transaction_id, percent, penal_no,schedule_number, penal_amount)
@classmethod
def get_last_penal_no(cls,loan_id):
return LoanCharge.get_last_penal_no(loan_id)
@classmethod
def get_penal_charges_by_loan_id(cls,loan_id):
return LoanCharge.get_penal_charges_by_loan_id(loan_id)
+27 -1
View File
@@ -13,6 +13,12 @@ class LoanRepaymentScheduleService:
def get_overdue_repayment_schedule(cls): def get_overdue_repayment_schedule(cls):
return LoanRepaymentSchedule.get_overdue_repayment_schedule() return LoanRepaymentSchedule.get_overdue_repayment_schedule()
@classmethod @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): 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) return LoanRepaymentSchedule.get_repayment_schedule_by_id_and_transaction_id(id, transaction_id)
@@ -26,13 +32,24 @@ class LoanRepaymentScheduleService:
Update repayment schedule status. Update repayment schedule status.
""" """
return LoanRepaymentSchedule.update_repayment_schedule_status(schedule_id) 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 @classmethod
def update_repayment_schedule_balance(cls, schedule_id, amount_collected): def update_repayment_schedule_balance(cls, schedule_id, amount_collected):
""" """
Update repayment schedule balance. Update repayment schedule balance.
""" """
return LoanRepaymentSchedule.update_repayment_schedule_balance(schedule_id, amount_collected) 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 @classmethod
def update_repayment_schedule_description(cls, schedule_id, description): def update_repayment_schedule_description(cls, schedule_id, description):
@@ -41,7 +58,16 @@ class LoanRepaymentScheduleService:
""" """
return LoanRepaymentSchedule.update_repayment_schedule_description(schedule_id, 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 @staticmethod
def handle_schedule_updates(updated_loan, data, amount_collected, message, loan_data): def handle_schedule_updates(updated_loan, data, amount_collected, message, loan_data):
""" """
+6
View File
@@ -216,6 +216,12 @@ paths:
responses: responses:
200: 200:
description: A successful response 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: /autocall/direct/loan:
post: post:
summary: Direct call for loan disbursement summary: Direct call for loan disbursement