Merge branch 'test' of DigiFi/digifi-EventManager into master

This commit is contained in:
2026-03-12 12:56:50 +00:00
committed by Gogs
7 changed files with 245 additions and 67 deletions
+18 -2
View File
@@ -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",
@@ -95,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
@@ -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()
+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
+35 -48
View File
@@ -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):
@@ -297,53 +301,36 @@ class LoanRepaymentSchedule(db.Model):
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 now = datetime.now(timezone.utc)
new_balance = outstanding_amount + penal_charge
db.session.commit() schedule.penal_count = (schedule.penal_count or 0) + 1
logger.info(f"""Updated due process date and count for schedule ID {schedule.id}, schedule.penal_charge = (schedule.penal_charge or 0) + penal_amount
amount due: {schedule.installment_amount}, schedule.last_penal_date = now
outstanding amount: {outstanding_amount}, schedule.due_process_date = now
due process count: {schedule.due_process_count}, schedule.updated_at = now
DPD: {dpd},
penal charge(1%): {penal_charge},
new balance(plus penal charge): {new_balance}
""")
except Exception as e: db.session.commit()
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
View File
@@ -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(
+3
View File
@@ -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)
+9 -3
View File
@@ -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):
""" """