diff --git a/app/enums/__init__.py b/app/enums/__init__.py index deb3f94..dc4aff8 100644 --- a/app/enums/__init__.py +++ b/app/enums/__init__.py @@ -1,2 +1,3 @@ from .transaction_type import TransactionType -from .loan_status import LoanStatus \ No newline at end of file +from .loan_status import LoanStatus +from .repayment_schedule_status import RepaymentScheduleStatus \ No newline at end of file diff --git a/app/enums/repayment_schedule_status.py b/app/enums/repayment_schedule_status.py new file mode 100644 index 0000000..3a1938f --- /dev/null +++ b/app/enums/repayment_schedule_status.py @@ -0,0 +1,5 @@ +from enum import Enum + +class RepaymentScheduleStatus(str, Enum): + PARTIALLY_PAID = "partially_paid" + REPAID = "repaid" \ No newline at end of file diff --git a/app/helpers/collect_loan_helper.py b/app/helpers/collect_loan_helper.py new file mode 100644 index 0000000..cdb614c --- /dev/null +++ b/app/helpers/collect_loan_helper.py @@ -0,0 +1,48 @@ +import random +import string +from app.services.repayment import RepaymentService +from app.services.loan import LoanService +from app.helpers.response_helper import ResponseHelper +from app.utils.logger import logger + +class CollectLoanHelper: + @staticmethod + def _validate_repayment_and_loan(data): + repayment = RepaymentService.get_repayment_by_id(id=data['Id']) + if not repayment: + logger.info(f"Repayment id: {data['Id']}, was not found") + return None, None, ResponseHelper.error("Repayment not found") + + repayment_data = repayment.to_dict() + loan = LoanService.get_loan_by_loan_id(loan_id=int(repayment_data['loanId'])) + if not loan: + logger.info(f"Loan id: {repayment_data['loanId']}, was not found") + return None, None, ResponseHelper.error("Loan not found") + + loan + return repayment_data, loan, None + + @staticmethod + def _build_collect_loan_payload(loan_data, repayment_data, data, collectionMethod): + debtId = str(loan_data.get('debtId', "")).strip().zfill(6) + t_id = ''.join(random.choices(string.ascii_uppercase, k=22)) + + return { + "transactionId": t_id, + "fbnTransactionId": loan_data['transactionId'], + "debtId": debtId, + "customerId": repayment_data['customerId'], + "accountId": loan_data['accountId'], + "productId": repayment_data['productId'], + "collectAmount": ( + data['overdueLoanScheduleAmount'] + if data.get('overdueLoanScheduleAmount') + is not None else loan_data.get('balance', 0) + ), + "penalCharge": 0, + "channel": "USSD", + "collectionMethod": collectionMethod, + "lienAmount": 0, + "countryId": "NG", + "comment": "COLLECT LOAN" + } diff --git a/app/integrations/simbrella.py b/app/integrations/simbrella.py index 727cbea..c8482df 100644 --- a/app/integrations/simbrella.py +++ b/app/integrations/simbrella.py @@ -6,7 +6,6 @@ from app.services.loan_repayment_schedule import LoanRepaymentScheduleService from app.utils.auth import get_headers from app.utils.extras import preprocess_loan_charges_data import random -import random import string from app.extensions import db from app.utils.logger import logger @@ -17,11 +16,13 @@ from app.extensions import db from app.services.repayments_data import RepaymentsData from app.services.salary import SalaryService from app.enums.loan_status import LoanStatus +from app.models.loan_repayment_schedule import LoanRepaymentSchedule from decimal import Decimal, ROUND_HALF_UP from requests.exceptions import SSLError, RequestException,Timeout import sys from requests.exceptions import ReadTimeout, ConnectTimeout import socket +from app.helpers.collect_loan_helper import CollectLoanHelper class SimbrellaClient: @@ -243,19 +244,10 @@ class SimbrellaClient: logger.info(f"Calling CollectLoan api_url==> : {api_url}") logger.info(f"Calling CollectLoan endpoint with data: {data}") - repayment = RepaymentService.get_repayment_by_id(id=data['Id']) - if not repayment: - logger.info(f"Repayment with id: {data['Id']} not found") - return ResponseHelper.error("Repayment not found") - - repayment_data = repayment.to_dict() - loan = LoanService.get_loan_by_loan_id(loan_id=int(repayment_data['loanId'])) - if not loan: - logger.info(f"Loan with debtId: {repayment_data['loanId']} not found") - return ResponseHelper.error("Loan not found") - + repayment_data, loan, error = CollectLoanHelper._validate_repayment_and_loan(data) + if error: + return error loan_data = loan.to_dict() - logger.info(f"Loan data: {loan_data}") if repayment_data['repayDate'] is not None: logger.info(f"Repayment already processed at {repayment_data['repayDate']}") @@ -265,28 +257,7 @@ class SimbrellaClient: repayment = RepaymentService.get_repayment_by_transaction_id(transaction_id=data['transactionId']) repayment_data = repayment.to_dict() - debtId = str(loan_data.get('debtId', "")).strip().zfill(6) - t_id = ''.join(random.choices(string.ascii_uppercase, k=22)) - collect_loan_data = { - "transactionId": t_id, - "fbnTransactionId": loan_data['transactionId'], - "debtId": debtId, - "customerId": repayment_data['customerId'], - "accountId": loan_data['accountId'], - "productId": repayment_data['productId'], - "collectAmount": ( - data['overdueLoanScheduleAmount'] - if data.get('overdueLoanScheduleAmount') is not None - else loan_data.get('balance', 0) - ), - "penalCharge": 0, - "channel": "USSD", - "collectionMethod": collectionMethod, - "lienAmount": 0, - "countryId": "NG", - "comment": "COLLECT LOAN" - } - + collect_loan_data = CollectLoanHelper._build_collect_loan_payload(loan_data, repayment_data, data, collectionMethod) try: logger.info(f"Sending CollectLoan request............ {collect_loan_data}") response = requests.post(api_url, json=collect_loan_data, timeout=90, headers=get_headers()) @@ -299,6 +270,9 @@ class SimbrellaClient: '404', 'Collection Service url not found' ) + if(data.get('overdueLoanScheduleId') is not None): + LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'], 'Collection Service url not found') + logger.error("Received 404 from external service") return ResponseHelper.error("Collection Service URL not found", status_code=404) @@ -329,6 +303,7 @@ class SimbrellaClient: else: logger.warning("Failed to add repayment data") updated_loan = None + response_message = result.get('responseMessage') if result.get('responseCode') == '00': amount_collected = Decimal(str(result.get('amountCollected', 0))).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) logger.info(f"Amount collected: {amount_collected}") @@ -363,66 +338,32 @@ class SimbrellaClient: logger.info(f'Updated loan with partial status: {partial}') updated_loan = partial - try: - schedule_to_update = LoanRepaymentScheduleService.get_repayment_schedule_by_id_and_transaction_id( - data["overdueLoanScheduleId"], data["transactionId"] - ) - logger.info(f"Schedule to update: {schedule_to_update}") - - if schedule_to_update is None: - logger.warning( - f"Repayment schedule not found for ID {data['overdueLoanScheduleId']} " - f"and transaction ID {loan_data['transactionId']}" - ) - else: - # Use DB installment amount as the reference - installment_amount = Decimal(str(schedule_to_update.installment_amount)).quantize(Decimal("0.01")) - - if amount_collected >= installment_amount: - LoanRepaymentScheduleService.update_repayment_schedule_status( - schedule_to_update.id, paid=True) - logger.info( - f"Installment {data['overdueLoanScheduleId']} fully covered " - f"with {amount_collected}, marked as paid" - ) - else: - logger.info( - f"Installment {data['overdueLoanScheduleId']} not fully covered. " - f"Collected={amount_collected}, " - f"Installment={installment_amount}" - ) - - except Exception as e: - logger.error(f"Failed to update repayment schedule installment: {e}") - - except Exception as e: db.session.rollback() logger.error(f"Error while updating loan status for debtId {updated_loan['debtId']}: {e}") - # for now lets update the loan repayment schedule only when full payment has been made + # lets update the loan repayment schedule when full payment has been made # get the repayment schedule by loan_id logger.info(f"Updated loan status: {updated_loan.get('status')}") - if updated_loan and updated_loan.get('status') == LoanStatus.REPAID: - repayment_schedule = LoanRepaymentScheduleService.get_repayment_schedule_by_loan_id(updated_loan['debtId']) - logger.info(f'Loan repayment schedule: {repayment_schedule}') - # if repayment_schedule, loop through each installment - if repayment_schedule: - for installment in repayment_schedule: - try: - logger.info(f'Processing installment: {installment}') - # Update each installment as paid - LoanRepaymentScheduleService.update_repayment_schedule_status(installment.id, True) - logger.info(f'Updated installment {installment.id} as paid') - except Exception as e: - logger.error(f"Failed to update installment {installment.id}: {e}") - - logger.info('All installments processed') - + LoanRepaymentScheduleService.handle_schedule_updates( + updated_loan=updated_loan, + data=data, + amount_collected=amount_collected, + message=response_message, + loan_data=loan_data + ) return ResponseHelper.success(result, "Successful") except SSLError as ssl_err: db.session.rollback() logger.exception(f"SSL error while calling Simbrella endpoint: {ssl_err}") + RepaymentService.set_repay_result( + repayment_data['Id'], + '502', + 'SSL error occurred while calling Simbrella' + ) + if(data.get('overdueLoanScheduleId') is not None): + LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'], 'SSL error occurred') + return ResponseHelper.error("SSL handshake failed with Simbrella", status_code=502, error=str(ssl_err)) except (Timeout, ReadTimeout, ConnectTimeout, socket.timeout, TimeoutError) as timeout_err: @@ -433,6 +374,9 @@ class SimbrellaClient: '500', 'There was a timeout while calling Simbrella' ) + if(data.get('overdueLoanScheduleId') is not None): + LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'], 'Timeout occurred') + return ResponseHelper.error("Connection to Simbrella timed out", status_code=504, error=str(timeout_err)) except RequestException as req_err: db.session.rollback() @@ -442,7 +386,8 @@ class SimbrellaClient: '500', 'There was a request error while calling Simbrella' ) - return ResponseHelper.error("Connection to Simbrella failed", status_code=503, error=str(req_err)) + if(data.get('overdueLoanScheduleId') is not None): + LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'], 'Request error occurred') except SystemExit as sys_exit: db.session.rollback() @@ -454,6 +399,9 @@ class SimbrellaClient: ) + if(data.get('overdueLoanScheduleId') is not None): + LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'], 'Unexpected shutdown occurred') + return ResponseHelper.error("Unexpected shutdown detected", status_code=500, error=str(sys_exit)) except Exception as e: @@ -464,6 +412,9 @@ class SimbrellaClient: '500', 'Unexpected error while processing loan collection' ) + if(data.get('overdueLoanScheduleId') is not None): + LoanRepaymentScheduleService.update_repayment_schedule_description(data['overdueLoanScheduleId'], 'Unexpected error occurred') + return ResponseHelper.error("Unexpected error while processing loan collection", status_code=500, error=str(e)) diff --git a/app/models/loan.py b/app/models/loan.py index 4017f7b..37aed8c 100644 --- a/app/models/loan.py +++ b/app/models/loan.py @@ -310,7 +310,7 @@ class Loan(db.Model): if not loan: raise ValueError(f"Loan with ID {loan_id} does not exist.") - + # Convert to Decimal and round to 2 decimal places amount_collected = Decimal(str(amount_collected)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) balance = Decimal(str(loan.balance or 0)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) @@ -321,7 +321,12 @@ class Loan(db.Model): if balance <= Decimal("0.00"): raise ValueError("There is no balance for this loan.") if amount_collected > balance: - raise ValueError("Repayment amount exceeds current loan balance.") + # allow tiny rounding diff + if abs(amount_collected - balance) <= Decimal("0.01"): + amount_collected = balance + else: + raise ValueError("Repayment amount exceeds current loan balance.") + # Deduct the amount from the current balance new_balance = balance - amount_collected diff --git a/app/models/loan_repayment_schedule.py b/app/models/loan_repayment_schedule.py index 96b3aa8..62e6d6b 100644 --- a/app/models/loan_repayment_schedule.py +++ b/app/models/loan_repayment_schedule.py @@ -2,8 +2,11 @@ from datetime import datetime, timezone from app.extensions import db from app.utils.logger import logger from sqlalchemy.exc import SQLAlchemyError -# from dateutil.relativedelta import relativedelta +from app.enums.repayment_schedule_status import RepaymentScheduleStatus +from decimal import Decimal, ROUND_HALF_UP +# from dateutil.relativedelta import relativedelta + class LoanRepaymentSchedule(db.Model): __tablename__ = 'loan_repayment_schedules' @@ -19,6 +22,9 @@ class LoanRepaymentSchedule(db.Model): paid_at = db.Column(db.DateTime, nullable=True) due_process_date = db.Column(db.DateTime, nullable=True) due_process_count = db.Column(db.Integer, default=0) + paid_status = db.Column(db.String(20), nullable=True) + repay_description = db.Column(db.String(255), nullable=True) + 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)) @@ -37,6 +43,9 @@ class LoanRepaymentSchedule(db.Model): 'paid': self.paid, 'due_process_date': self.due_process_date.isoformat() if self.due_process_date else None, 'due_process_count': self.due_process_count, + 'paid_status': self.paid_status, + 'repay_description': self.repay_description, + '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 @@ -46,15 +55,26 @@ class LoanRepaymentSchedule(db.Model): return f'' @classmethod - def get_repayment_schedule_by_loan_id(cls, loan_id): + def get_repayment_schedule_by_loan_id(cls, loan_id, include_paid=True): """ - Get repayment schedule by loan ID + Get repayment schedules by loan ID. + + :param loan_id: Loan ID to filter by + :param include_paid: If True, include all schedules. If False, only unpaid ones. + :return: List of repayment schedules ordered by due_date """ try: - return cls.query.filter_by(loan_id=loan_id).all() + query = cls.query.filter_by(loan_id=loan_id) + if not include_paid: + query = query.filter_by(paid=False) + + schedules = query.order_by(cls.due_date.asc()).all() + return schedules + except Exception as e: - logger.error(f"Error fetching repayment schedule for loan_id={loan_id}: {e}") - return [] + logger.error(f"Error fetching repayment schedules for loan {loan_id}: {e}") + raise + @classmethod def get_repayment_schedule_by_id_and_transaction_id(cls, id, transaction_id): """ @@ -83,42 +103,124 @@ class LoanRepaymentSchedule(db.Model): Get repayment schedule by transaction ID """ return cls.query.filter_by(transaction_id=transaction_id).all() - @classmethod - def update_repayment_schedule_status(cls, schedule_id, paid=False): + def update_repayment_schedule_description(cls, schedule_id, description): """ - Update repayment schedule status. - Args: - schedule_id (int): ID of the repayment schedule. - paid (bool): Whether the repayment is marked as paid or not. - Returns: - schedule (RepaymentSchedule | None): The updated schedule object, or None if not found. + Update the repayment description for a specific schedule. """ try: - # Fetch the repayment schedule by ID schedule = cls.query.get(schedule_id) + if not schedule: + raise ValueError(f"Schedule with ID {schedule_id} does not exist.") - if schedule: - # Update payment status - schedule.paid = paid - schedule.paid_at = datetime.now(timezone.utc) if paid else None - 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.repay_description = description + schedule.updated_at = datetime.now(timezone.utc) - # Commit changes to the database - db.session.commit() + db.session.commit() + logger.info(f"Updated repayment description for schedule ID {schedule_id}") - return schedule - - except SQLAlchemyError as e: - # Rollback changes if something goes wrong at the DB level - db.session.rollback() - logger.error(f"Database error updating repayment schedule {schedule_id}: {e}") - return None + return schedule.to_dict() except Exception as e: - # Catch any other unexpected error - logger.error(f"Unexpected error updating repayment schedule {schedule_id}: {e}") - return None \ No newline at end of file + db.session.rollback() + logger.error(f"Error updating repayment description for schedule {schedule_id}: {e}") + raise + @classmethod + def update_repayment_schedule_status(cls, schedule_id): + """ + Mark a repayment schedule as fully repaid when the parent loan is fully repaid. + This function does not take amount_collected because the loan is already cleared. + """ + try: + # Fetch schedule + schedule = cls.query.get(schedule_id) + if not schedule: + raise ValueError(f"Schedule with ID {schedule_id} does not exist.") + + # Force balance to 0 + schedule.partial_balance = 0.0 + schedule.paid_status = RepaymentScheduleStatus.REPAID + schedule.paid = True + schedule.paid_at = datetime.now(timezone.utc) + + # Track due processing + 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) + + # Update timestamp + schedule.updated_at = datetime.now(timezone.utc) + + # Commit changes + db.session.commit() + logger.info(f"Schedule {schedule_id} marked as REPAID since parent loan is fully repaid.") + + return schedule.to_dict() + + except Exception as e: + db.session.rollback() + logger.error(f"Error updating repayment schedule {schedule_id} after loan repayment: {e}") + raise + + + @classmethod + def update_repayment_schedule_balance(cls, schedule_id, amount_collected): + """ + Apply repayment to a loan schedule: + - Deduct from partial balance if partially paid. + - Otherwise deduct from installment amount. + - Update partial balance, paid status, timestamps, etc. + """ + try: + schedule = cls.query.get(schedule_id) + if not schedule: + raise ValueError(f"Schedule with ID {schedule_id} does not exist.") + + # Normalize amount + amount_collected = Decimal(str(amount_collected)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + if amount_collected <= Decimal("0.00"): + logger.info("Repayment amount must be greater than zero.") + return schedule.to_dict() + + # Determine current balance + if schedule.paid_status == RepaymentScheduleStatus.PARTIALLY_PAID and (schedule.partial_balance or 0) > 0: + balance = Decimal(str(schedule.partial_balance)) + else: + balance = Decimal(str(schedule.installment_amount)) + + # Deduct repayment + new_balance = balance - amount_collected + if new_balance < 0: + new_balance = Decimal("0.00") # prevent negatives + + # Update schedule fields + schedule.partial_balance = float(new_balance) if new_balance > 0 else 0.0 + schedule.updated_at = datetime.now(timezone.utc) + + if new_balance == 0: + schedule.paid_status = RepaymentScheduleStatus.REPAID + schedule.paid = True + schedule.paid_at = datetime.now(timezone.utc) + else: + schedule.paid_status = RepaymentScheduleStatus.PARTIALLY_PAID + schedule.paid = False # not fully paid yet + + # Track due processing + 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) + + # Commit + db.session.commit() + logger.info(f"Repayment applied for schedule ID {schedule_id}. Remaining balance: {schedule.partial_balance}") + + return schedule.to_dict() + + except Exception as e: + db.session.rollback() + logger.error(f"Error applying repayment for schedule {schedule_id}: {e}") + raise + + \ No newline at end of file diff --git a/app/routes/autocall.py b/app/routes/autocall.py index 46492ef..86fcd8f 100644 --- a/app/routes/autocall.py +++ b/app/routes/autocall.py @@ -11,6 +11,7 @@ from app.services.repayment import RepaymentService from app.services.salary import SalaryService from app.services.loan_repayment_schedule import LoanRepaymentScheduleService 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 app.config import settings @@ -294,7 +295,7 @@ def process_overdue_loan(loan): #lets check if the loan with the repayment has been repaid, then update the loan schedule to paid if full_loan_data.to_dict().get("status") == LoanStatus.REPAID and full_loan_data.to_dict().get("balance") == 0: try: - LoanRepaymentScheduleService.update_repayment_schedule_status(loan.id, paid=True) + LoanRepaymentScheduleService.update_repayment_schedule_status(loan.id) logger.info(f"Updated Loan Repayment Schedule ID {loan.id} to PAID") return except Exception as e: @@ -335,8 +336,12 @@ def process_overdue_loan(loan): # Step 3: Call Simbrella try: #lets add the overdue loan schedule id and amount we are currently processing to the repayment data + if loan.paid_status == RepaymentScheduleStatus.PARTIALLY_PAID: + amount = loan.partial_balance or 0 + else: + amount = loan.installment_amount + repayment_data["overdueLoanScheduleAmount"] = amount repayment_data["overdueLoanScheduleId"] = loan.id - repayment_data["overdueLoanScheduleAmount"] = loan.installment_amount repayment_data["Id"] = repayment.id simbrella_response = SimbrellaClient.collect_loan_user_due_payment(repayment_data) diff --git a/app/services/loan.py b/app/services/loan.py index bb4e2d8..f8e3e16 100644 --- a/app/services/loan.py +++ b/app/services/loan.py @@ -106,3 +106,5 @@ class LoanService: Get all overdue loans. """ return Loan.get_overdue_loans() + + \ No newline at end of file diff --git a/app/services/loan_repayment_schedule.py b/app/services/loan_repayment_schedule.py index a446d65..b1a3153 100644 --- a/app/services/loan_repayment_schedule.py +++ b/app/services/loan_repayment_schedule.py @@ -1,11 +1,13 @@ from app.models.loan_repayment_schedule import LoanRepaymentSchedule +from app.utils.logger import logger +from app.enums.loan_status import LoanStatus class LoanRepaymentScheduleService: @classmethod - def get_repayment_schedule_by_loan_id(cls, loan_id): - return LoanRepaymentSchedule.get_repayment_schedule_by_loan_id(loan_id) + def get_repayment_schedule_by_loan_id(cls, loan_id, include_paid=True): + return LoanRepaymentSchedule.get_repayment_schedule_by_loan_id(loan_id, include_paid=include_paid) @classmethod def get_overdue_repayment_schedule(cls): return LoanRepaymentSchedule.get_overdue_repayment_schedule() @@ -16,10 +18,88 @@ class LoanRepaymentScheduleService: @classmethod def get_repayment_schedule_by_transaction_id(cls, transaction_id): return LoanRepaymentSchedule.get_repayment_schedule_by_transaction_id(transaction_id) - + @classmethod - def update_repayment_schedule_status(cls, schedule_id, paid): + def update_repayment_schedule_status(cls, schedule_id): """ Update repayment schedule status. """ - return LoanRepaymentSchedule.update_repayment_schedule_status(schedule_id, paid) \ No newline at end of file + return LoanRepaymentSchedule.update_repayment_schedule_status(schedule_id) + + @classmethod + def update_repayment_schedule_balance(cls, schedule_id, amount_collected): + """ + Update repayment schedule balance. + """ + return LoanRepaymentSchedule.update_repayment_schedule_balance(schedule_id, amount_collected) + + @classmethod + def update_repayment_schedule_description(cls, schedule_id, description): + """ + Update repayment schedule description. + """ + return LoanRepaymentSchedule.update_repayment_schedule_description(schedule_id, description) + + @staticmethod + def handle_schedule_updates(updated_loan, data, amount_collected, message, loan_data): + """ + Handles updating loan repayment schedules depending on loan status + and overdue schedule data. + """ + try: + # Case 1: Loan fully repaid → mark all schedules paid + if updated_loan and updated_loan.get('status') == LoanStatus.REPAID: + repayment_schedule = LoanRepaymentScheduleService.get_repayment_schedule_by_loan_id( + updated_loan['debtId'], include_paid=False + ) + logger.info(f'Loan repayment schedule: {repayment_schedule}') + + if repayment_schedule: + for installment in repayment_schedule: + try: + logger.info(f'Processing installment: {installment}') + LoanRepaymentScheduleService.update_repayment_schedule_status(installment.id) + LoanRepaymentScheduleService.update_repayment_schedule_description( + installment.id, + message + ) + logger.info(f'Updated installment {installment.id} as paid') + except Exception as e: + logger.error(f"Failed to update installment {installment.id}: {e}") + logger.info('All installments processed') + + # Case 2: Partial repayment without overdueLoanScheduleId + elif updated_loan and updated_loan.get('status') == LoanStatus.ACTIVE_PARTIAL and not data.get('overdueLoanScheduleId'): + logger.info("Partial repayment detected, but no overdue schedule ID provided.") + # TODO: implement proportional installment updates + + # Case 3: Overdue schedule repayment → update balance & description + elif data.get('overdueLoanScheduleId') is not None: + logger.info(f"Overdue loan schedule ID: {data['overdueLoanScheduleId']}") + try: + schedule_to_update = LoanRepaymentScheduleService.get_repayment_schedule_by_id_and_transaction_id( + data["overdueLoanScheduleId"], data["transactionId"] + ) + logger.info(f"Schedule to update: {schedule_to_update}") + + if schedule_to_update is None: + logger.warning( + f"Repayment schedule not found for ID {data['overdueLoanScheduleId']} " + f"and transaction ID {loan_data['transactionId']}" + ) + else: + if not schedule_to_update.paid: + update_schedule_balance = LoanRepaymentScheduleService.update_repayment_schedule_balance( + schedule_to_update.id, amount_collected + ) + logger.info(f"Updated loan schedule balance: {update_schedule_balance}") + LoanRepaymentScheduleService.update_repayment_schedule_description( + schedule_to_update.id, + message + ) + except Exception as e: + logger.error(f"Failed to update repayment schedule installment: {e}") + + except Exception as e: + logger.error(f"Unexpected error while handling schedule updates: {e}") +