Merge branch 'test' of DigiFi/digifi-EventManager into master
This commit is contained in:
+19
-3
@@ -47,8 +47,8 @@ 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)
|
total_penal_charge = db.Column(db.Float, default=0.0)
|
||||||
# last_penal_date = db.Column(db.DateTime, nullable=True)
|
last_penal_date = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
customer = relationship(
|
customer = relationship(
|
||||||
"Customer",
|
"Customer",
|
||||||
@@ -94,7 +94,9 @@ class Loan(db.Model):
|
|||||||
'disburseVerify': self.disburse_verify.isoformat() if self.disburse_verify else None,
|
'disburseVerify': self.disburse_verify.isoformat() if self.disburse_verify else None,
|
||||||
'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
|
||||||
@@ -397,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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -28,9 +28,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_charge = db.Column(db.Float, default=0.0)
|
penal_count = db.Column(db.Integer, default=0)
|
||||||
# penal_count = db.Column(db.Integer, default=0)
|
last_penal_date = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -52,7 +52,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):
|
||||||
@@ -296,54 +300,37 @@ 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
|
||||||
@classmethod
|
@classmethod
|
||||||
def update_due_process_date_and_count(cls, schedule_id):
|
def apply_penal_to_schedule(cls, schedule_id, penal_amount):
|
||||||
"""
|
|
||||||
Update the due process date to now and increment the due process count.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
schedule = cls.query.get(schedule_id)
|
|
||||||
if not schedule:
|
|
||||||
raise ValueError(f"Schedule with ID {schedule_id} does not exist.")
|
|
||||||
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)
|
|
||||||
schedule.updated_at = datetime.now(timezone.utc)
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
# Calculate DPD
|
|
||||||
dpd = max((now.date() - schedule.due_date.date()).days, 0)
|
|
||||||
# Determine outstanding balance
|
|
||||||
if schedule.paid_status == RepaymentScheduleStatus.PARTIALLY_PAID and schedule.partial_balance:
|
|
||||||
outstanding_amount = Decimal(str(schedule.partial_balance))
|
|
||||||
else:
|
|
||||||
outstanding_amount = Decimal(str(schedule.installment_amount))
|
|
||||||
|
|
||||||
# Calculate penal charge (1% default if settings not set)
|
schedule = cls.query.get(schedule_id)
|
||||||
try:
|
|
||||||
penalty_rate = getattr(settings, "PENAL_CHARGE_PERCENTAGE", 1)/100
|
|
||||||
except:
|
|
||||||
penalty_rate = 0.01
|
|
||||||
penal_charge = (outstanding_amount * Decimal(str(penalty_rate))).quantize(
|
|
||||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
|
||||||
)
|
|
||||||
|
|
||||||
#new balance + penal charge
|
|
||||||
new_balance = outstanding_amount + penal_charge
|
|
||||||
|
|
||||||
db.session.commit()
|
now = datetime.now(timezone.utc)
|
||||||
logger.info(f"""Updated due process date and count for schedule ID {schedule.id},
|
|
||||||
amount due: {schedule.installment_amount},
|
schedule.penal_count = (schedule.penal_count or 0) + 1
|
||||||
outstanding amount: {outstanding_amount},
|
schedule.penal_charge = (schedule.penal_charge or 0) + penal_amount
|
||||||
due process count: {schedule.due_process_count},
|
schedule.last_penal_date = now
|
||||||
DPD: {dpd},
|
schedule.due_process_date = now
|
||||||
penal charge(1%): {penal_charge},
|
schedule.updated_at = now
|
||||||
new balance(plus penal charge): {new_balance}
|
|
||||||
""")
|
db.session.commit()
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
logger.error(f"Error updating due process date and count for schedule {schedule.id}: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
+84
-9
@@ -12,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__)
|
||||||
@@ -446,26 +448,99 @@ def process_penal_charges():
|
|||||||
try:
|
try:
|
||||||
OVERDUE_GRACE_PERIOD_DAYS = settings.OVERDUE_GRACE_PERIOD_DAYS
|
OVERDUE_GRACE_PERIOD_DAYS = settings.OVERDUE_GRACE_PERIOD_DAYS
|
||||||
OVERDUE_PROCESSING_LIST_LIMIT = settings.OVERDUE_PROCESSING_LIST_LIMIT
|
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
|
||||||
|
|
||||||
overdue_loans = (
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
overdue_schedules = (
|
||||||
LoanRepaymentScheduleService
|
LoanRepaymentScheduleService
|
||||||
.get_overdue_repayment_schedule_with_grace_period(OVERDUE_GRACE_PERIOD_DAYS,OVERDUE_PROCESSING_LIST_LIMIT)
|
.get_overdue_repayment_schedule_with_grace_period(
|
||||||
|
OVERDUE_GRACE_PERIOD_DAYS,
|
||||||
|
OVERDUE_PROCESSING_LIST_LIMIT
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Found {len(overdue_loans)} overdue loans.")
|
logger.info(f"Found {len(overdue_schedules)} overdue loan schedule.")
|
||||||
|
|
||||||
if not overdue_loans:
|
if not overdue_schedules:
|
||||||
return ResponseHelper.success(
|
return ResponseHelper.success(
|
||||||
message="No overdue loans found",
|
message="No overdue loan schedule found",
|
||||||
status_code=200
|
status_code=200
|
||||||
)
|
)
|
||||||
|
|
||||||
processed_loans = []
|
processed_loans = []
|
||||||
|
|
||||||
for loan in overdue_loans:
|
for schedule in overdue_schedules:
|
||||||
# TODO: apply penal charge logic here
|
|
||||||
# PenalChargeService.apply_penal_charge(loan)
|
loan = LoanService.get_loan_by_loan_id(schedule.loan_id)
|
||||||
LoanRepaymentScheduleService.update_due_process_date_and_count(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())
|
processed_loans.append(loan.to_dict())
|
||||||
|
|
||||||
return ResponseHelper.success(
|
return ResponseHelper.success(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -44,6 +44,12 @@ class LoanRepaymentScheduleService:
|
|||||||
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):
|
||||||
@@ -57,11 +63,11 @@ class LoanRepaymentScheduleService:
|
|||||||
return LoanRepaymentSchedule.get_overdue_repayment_schedule_with_grace_period(grace_period_days, limit=limit)
|
return LoanRepaymentSchedule.get_overdue_repayment_schedule_with_grace_period(grace_period_days, limit=limit)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def update_due_process_date_and_count(cls, schedule_id):
|
def apply_penal_to_schedule(cls, schedule_id, penal_amount):
|
||||||
"""
|
"""
|
||||||
Update due process date and count for a repayment schedule.
|
Apply penal charge to a repayment schedule.
|
||||||
"""
|
"""
|
||||||
return LoanRepaymentSchedule.update_due_process_date_and_count(schedule_id)
|
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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user