diff --git a/app/models/loan.py b/app/models/loan.py index 1a9de17..fae9acc 100644 --- a/app/models/loan.py +++ b/app/models/loan.py @@ -47,8 +47,8 @@ class Loan(db.Model): 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) + total_penal_charge = db.Column(db.Float, default=0.0) + last_penal_date = db.Column(db.DateTime, nullable=True) customer = relationship( "Customer", @@ -94,7 +94,9 @@ class Loan(db.Model): 'disburseVerify': self.disburse_verify.isoformat() if self.disburse_verify else None, 'reference': self.reference, 'balance': self.balance, - 'tenor': self.tenor, + 'tenor': self.tenor, + 'totalPenalCharge': self.total_penal_charge, + 'lastPenalDate': self.last_penal_date } @classmethod @@ -397,3 +399,17 @@ class Loan(db.Model): except Exception as e: logger.error(f"Error fetching overdue loans: {e}") 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() diff --git a/app/models/loan_charge.py b/app/models/loan_charge.py index 0a04789..9684103 100644 --- a/app/models/loan_charge.py +++ b/app/models/loan_charge.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone, timedelta from os.path import devnull - +from sqlalchemy.exc import IntegrityError from app.extensions import db from sqlalchemy.orm import relationship @@ -43,7 +43,85 @@ class LoanCharge(db.Model): 'description': self.description, '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 def get_loan_charge_by_debt_id(cls, debt_id): - return cls.query.filter_by(loan_id=debt_id) \ No newline at end of file + 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 \ No newline at end of file diff --git a/app/models/loan_repayment_schedule.py b/app/models/loan_repayment_schedule.py index d4175a6..dcba841 100644 --- a/app/models/loan_repayment_schedule.py +++ b/app/models/loan_repayment_schedule.py @@ -28,9 +28,9 @@ class LoanRepaymentSchedule(db.Model): partial_balance = db.Column(db.Float, default=0.0) 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)) - - # penal_charge = db.Column(db.Float, default=0.0) - # penal_count = db.Column(db.Integer, default=0) + 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) @@ -52,7 +52,11 @@ class LoanRepaymentSchedule(db.Model): 'partial_balance': self.partial_balance, 'paid_at': self.paid_at.isoformat() if self.paid_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): @@ -296,54 +300,37 @@ class LoanRepaymentSchedule(db.Model): db.session.rollback() logger.error(f"Error applying repayment for schedule {schedule_id}: {e}") raise - @classmethod - def update_due_process_date_and_count(cls, schedule_id): - """ - 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)) + @classmethod + def apply_penal_to_schedule(cls, schedule_id, penal_amount): - # Calculate penal charge (1% default if settings not set) - 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 + schedule = cls.query.get(schedule_id) - db.session.commit() - logger.info(f"""Updated due process date and count for schedule ID {schedule.id}, - amount due: {schedule.installment_amount}, - outstanding amount: {outstanding_amount}, - due process count: {schedule.due_process_count}, - DPD: {dpd}, - penal charge(1%): {penal_charge}, - new balance(plus penal charge): {new_balance} - """) + now = datetime.now(timezone.utc) + + schedule.penal_count = (schedule.penal_count or 0) + 1 + schedule.penal_charge = (schedule.penal_charge or 0) + penal_amount + schedule.last_penal_date = now + schedule.due_process_date = now + schedule.updated_at = now + + 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 + \ No newline at end of file diff --git a/app/routes/autocall.py b/app/routes/autocall.py index 8379c95..c6f669d 100644 --- a/app/routes/autocall.py +++ b/app/routes/autocall.py @@ -12,9 +12,11 @@ from app.services.loan import LoanService from app.services.repayment import RepaymentService from app.services.salary import SalaryService 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.repayment_schedule_status import RepaymentScheduleStatus from app.utils.mail import send_report_email, get_report_data +from datetime import datetime, timezone, timedelta from app.config import settings autocall_bp = Blueprint("autocall", __name__) @@ -446,26 +448,99 @@ 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 - overdue_loans = ( + now = datetime.now(timezone.utc) + + overdue_schedules = ( 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( - message="No overdue loans found", + message="No overdue loan schedule found", status_code=200 ) processed_loans = [] - for loan in overdue_loans: - # TODO: apply penal charge logic here - # PenalChargeService.apply_penal_charge(loan) - LoanRepaymentScheduleService.update_due_process_date_and_count(loan.id) + 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( diff --git a/app/services/loan.py b/app/services/loan.py index 693c865..d090dbf 100644 --- a/app/services/loan.py +++ b/app/services/loan.py @@ -117,6 +117,9 @@ class LoanService: Get all 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 def _update_loan_after_collection(loan, loan_data, updated_loan, amount_collected, data, response_message): if loan.balance is None or loan.balance <= 0: diff --git a/app/services/loan_charge.py b/app/services/loan_charge.py new file mode 100644 index 0000000..b07cb8e --- /dev/null +++ b/app/services/loan_charge.py @@ -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) \ No newline at end of file diff --git a/app/services/loan_repayment_schedule.py b/app/services/loan_repayment_schedule.py index 5c44c0e..de3f4e6 100644 --- a/app/services/loan_repayment_schedule.py +++ b/app/services/loan_repayment_schedule.py @@ -44,6 +44,12 @@ class LoanRepaymentScheduleService: Update repayment schedule balance. """ 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 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) @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 def handle_schedule_updates(updated_loan, data, amount_collected, message, loan_data): """