added repayment_schedule

This commit is contained in:
Chinenye Nmoh
2025-08-27 12:00:42 +01:00
parent 524836f52a
commit fb460471fb
9 changed files with 330 additions and 131 deletions
+1
View File
@@ -1,2 +1,3 @@
from .transaction_type import TransactionType from .transaction_type import TransactionType
from .loan_status import LoanStatus from .loan_status import LoanStatus
from .repayment_schedule_status import RepaymentScheduleStatus
+5
View File
@@ -0,0 +1,5 @@
from enum import Enum
class RepaymentScheduleStatus(str, Enum):
PARTIALLY_PAID = "partially_paid"
REPAID = "repaid"
+48
View File
@@ -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"
}
+37 -86
View File
@@ -6,7 +6,6 @@ from app.services.loan_repayment_schedule import LoanRepaymentScheduleService
from app.utils.auth import get_headers from app.utils.auth import get_headers
from app.utils.extras import preprocess_loan_charges_data from app.utils.extras import preprocess_loan_charges_data
import random import random
import random
import string import string
from app.extensions import db from app.extensions import db
from app.utils.logger import logger 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.repayments_data import RepaymentsData
from app.services.salary import SalaryService from app.services.salary import SalaryService
from app.enums.loan_status import LoanStatus from app.enums.loan_status import LoanStatus
from app.models.loan_repayment_schedule import LoanRepaymentSchedule
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
from requests.exceptions import SSLError, RequestException,Timeout from requests.exceptions import SSLError, RequestException,Timeout
import sys import sys
from requests.exceptions import ReadTimeout, ConnectTimeout from requests.exceptions import ReadTimeout, ConnectTimeout
import socket import socket
from app.helpers.collect_loan_helper import CollectLoanHelper
class SimbrellaClient: class SimbrellaClient:
@@ -243,19 +244,10 @@ class SimbrellaClient:
logger.info(f"Calling CollectLoan api_url==> : {api_url}") logger.info(f"Calling CollectLoan api_url==> : {api_url}")
logger.info(f"Calling CollectLoan endpoint with data: {data}") logger.info(f"Calling CollectLoan endpoint with data: {data}")
repayment = RepaymentService.get_repayment_by_id(id=data['Id']) repayment_data, loan, error = CollectLoanHelper._validate_repayment_and_loan(data)
if not repayment: if error:
logger.info(f"Repayment with id: {data['Id']} not found") return error
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")
loan_data = loan.to_dict() loan_data = loan.to_dict()
logger.info(f"Loan data: {loan_data}")
if repayment_data['repayDate'] is not None: if repayment_data['repayDate'] is not None:
logger.info(f"Repayment already processed at {repayment_data['repayDate']}") 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 = RepaymentService.get_repayment_by_transaction_id(transaction_id=data['transactionId'])
repayment_data = repayment.to_dict() repayment_data = repayment.to_dict()
debtId = str(loan_data.get('debtId', "")).strip().zfill(6) collect_loan_data = CollectLoanHelper._build_collect_loan_payload(loan_data, repayment_data, data, collectionMethod)
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"
}
try: try:
logger.info(f"Sending CollectLoan request............ {collect_loan_data}") logger.info(f"Sending CollectLoan request............ {collect_loan_data}")
response = requests.post(api_url, json=collect_loan_data, timeout=90, headers=get_headers()) response = requests.post(api_url, json=collect_loan_data, timeout=90, headers=get_headers())
@@ -299,6 +270,9 @@ class SimbrellaClient:
'404', '404',
'Collection Service url not found' '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") logger.error("Received 404 from external service")
return ResponseHelper.error("Collection Service URL not found", status_code=404) return ResponseHelper.error("Collection Service URL not found", status_code=404)
@@ -329,6 +303,7 @@ class SimbrellaClient:
else: else:
logger.warning("Failed to add repayment data") logger.warning("Failed to add repayment data")
updated_loan = None updated_loan = None
response_message = result.get('responseMessage')
if result.get('responseCode') == '00': if result.get('responseCode') == '00':
amount_collected = Decimal(str(result.get('amountCollected', 0))).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) amount_collected = Decimal(str(result.get('amountCollected', 0))).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
logger.info(f"Amount collected: {amount_collected}") logger.info(f"Amount collected: {amount_collected}")
@@ -363,66 +338,32 @@ class SimbrellaClient:
logger.info(f'Updated loan with partial status: {partial}') logger.info(f'Updated loan with partial status: {partial}')
updated_loan = 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: except Exception as e:
db.session.rollback() db.session.rollback()
logger.error(f"Error while updating loan status for debtId {updated_loan['debtId']}: {e}") 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 # get the repayment schedule by loan_id
logger.info(f"Updated loan status: {updated_loan.get('status')}") logger.info(f"Updated loan status: {updated_loan.get('status')}")
if updated_loan and updated_loan.get('status') == LoanStatus.REPAID: LoanRepaymentScheduleService.handle_schedule_updates(
repayment_schedule = LoanRepaymentScheduleService.get_repayment_schedule_by_loan_id(updated_loan['debtId']) updated_loan=updated_loan,
logger.info(f'Loan repayment schedule: {repayment_schedule}') data=data,
# if repayment_schedule, loop through each installment amount_collected=amount_collected,
if repayment_schedule: message=response_message,
for installment in repayment_schedule: loan_data=loan_data
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')
return ResponseHelper.success(result, "Successful") return ResponseHelper.success(result, "Successful")
except SSLError as ssl_err: except SSLError as ssl_err:
db.session.rollback() db.session.rollback()
logger.exception(f"SSL error while calling Simbrella endpoint: {ssl_err}") 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)) 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: except (Timeout, ReadTimeout, ConnectTimeout, socket.timeout, TimeoutError) as timeout_err:
@@ -433,6 +374,9 @@ class SimbrellaClient:
'500', '500',
'There was a timeout while calling Simbrella' '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)) return ResponseHelper.error("Connection to Simbrella timed out", status_code=504, error=str(timeout_err))
except RequestException as req_err: except RequestException as req_err:
db.session.rollback() db.session.rollback()
@@ -442,7 +386,8 @@ class SimbrellaClient:
'500', '500',
'There was a request error while calling Simbrella' '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: except SystemExit as sys_exit:
db.session.rollback() 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)) return ResponseHelper.error("Unexpected shutdown detected", status_code=500, error=str(sys_exit))
except Exception as e: except Exception as e:
@@ -464,6 +412,9 @@ class SimbrellaClient:
'500', '500',
'Unexpected error while processing loan collection' '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)) return ResponseHelper.error("Unexpected error while processing loan collection", status_code=500, error=str(e))
+6 -1
View File
@@ -321,7 +321,12 @@ class Loan(db.Model):
if balance <= Decimal("0.00"): if balance <= Decimal("0.00"):
raise ValueError("There is no balance for this loan.") raise ValueError("There is no balance for this loan.")
if amount_collected > balance: 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 # Deduct the amount from the current balance
new_balance = balance - amount_collected new_balance = balance - amount_collected
+136 -34
View File
@@ -2,6 +2,9 @@ from datetime import datetime, 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 app.enums.repayment_schedule_status import RepaymentScheduleStatus
from decimal import Decimal, ROUND_HALF_UP
# from dateutil.relativedelta import relativedelta # from dateutil.relativedelta import relativedelta
class LoanRepaymentSchedule(db.Model): class LoanRepaymentSchedule(db.Model):
@@ -19,6 +22,9 @@ class LoanRepaymentSchedule(db.Model):
paid_at = db.Column(db.DateTime, nullable=True) paid_at = db.Column(db.DateTime, nullable=True)
due_process_date = db.Column(db.DateTime, nullable=True) due_process_date = db.Column(db.DateTime, nullable=True)
due_process_count = db.Column(db.Integer, default=0) 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)) 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))
@@ -37,6 +43,9 @@ class LoanRepaymentSchedule(db.Model):
'paid': self.paid, 'paid': self.paid,
'due_process_date': self.due_process_date.isoformat() if self.due_process_date else None, 'due_process_date': self.due_process_date.isoformat() if self.due_process_date else None,
'due_process_count': self.due_process_count, '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, '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
@@ -46,15 +55,26 @@ class LoanRepaymentSchedule(db.Model):
return f'<LoanRepaymentSchedule Loan:{self.loan_id} Installment:{self.installment_number}>' return f'<LoanRepaymentSchedule Loan:{self.loan_id} Installment:{self.installment_number}>'
@classmethod @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: 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: except Exception as e:
logger.error(f"Error fetching repayment schedule for loan_id={loan_id}: {e}") logger.error(f"Error fetching repayment schedules for loan {loan_id}: {e}")
return [] raise
@classmethod @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):
""" """
@@ -83,42 +103,124 @@ class LoanRepaymentSchedule(db.Model):
Get repayment schedule by transaction ID Get repayment schedule by transaction ID
""" """
return cls.query.filter_by(transaction_id=transaction_id).all() return cls.query.filter_by(transaction_id=transaction_id).all()
@classmethod @classmethod
def update_repayment_schedule_status(cls, schedule_id, paid=False): def update_repayment_schedule_description(cls, schedule_id, description):
""" """
Update repayment schedule status. Update the repayment description for a specific schedule.
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.
""" """
try: try:
# Fetch the repayment schedule by ID
schedule = cls.query.get(schedule_id) schedule = cls.query.get(schedule_id)
if not schedule:
raise ValueError(f"Schedule with ID {schedule_id} does not exist.")
if schedule: schedule.repay_description = description
# Update payment status schedule.updated_at = datetime.now(timezone.utc)
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)
# Commit changes to the database db.session.commit()
db.session.commit() logger.info(f"Updated repayment description for schedule ID {schedule_id}")
return schedule return schedule.to_dict()
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
except Exception as e: except Exception as e:
# Catch any other unexpected error db.session.rollback()
logger.error(f"Unexpected error updating repayment schedule {schedule_id}: {e}") logger.error(f"Error updating repayment description for schedule {schedule_id}: {e}")
return None 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
+7 -2
View File
@@ -11,6 +11,7 @@ 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.enums.loan_status import LoanStatus 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.utils.mail import send_report_email, get_report_data
from app.config import settings 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 #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: if full_loan_data.to_dict().get("status") == LoanStatus.REPAID and full_loan_data.to_dict().get("balance") == 0:
try: 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") logger.info(f"Updated Loan Repayment Schedule ID {loan.id} to PAID")
return return
except Exception as e: except Exception as e:
@@ -335,8 +336,12 @@ def process_overdue_loan(loan):
# Step 3: Call Simbrella # Step 3: Call Simbrella
try: try:
#lets add the overdue loan schedule id and amount we are currently processing to the repayment data #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["overdueLoanScheduleId"] = loan.id
repayment_data["overdueLoanScheduleAmount"] = loan.installment_amount
repayment_data["Id"] = repayment.id repayment_data["Id"] = repayment.id
simbrella_response = SimbrellaClient.collect_loan_user_due_payment(repayment_data) simbrella_response = SimbrellaClient.collect_loan_user_due_payment(repayment_data)
+2
View File
@@ -106,3 +106,5 @@ class LoanService:
Get all overdue loans. Get all overdue loans.
""" """
return Loan.get_overdue_loans() return Loan.get_overdue_loans()
+84 -4
View File
@@ -1,11 +1,13 @@
from app.models.loan_repayment_schedule import LoanRepaymentSchedule from app.models.loan_repayment_schedule import LoanRepaymentSchedule
from app.utils.logger import logger
from app.enums.loan_status import LoanStatus
class LoanRepaymentScheduleService: class LoanRepaymentScheduleService:
@classmethod @classmethod
def get_repayment_schedule_by_loan_id(cls, 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) return LoanRepaymentSchedule.get_repayment_schedule_by_loan_id(loan_id, include_paid=include_paid)
@classmethod @classmethod
def get_overdue_repayment_schedule(cls): def get_overdue_repayment_schedule(cls):
return LoanRepaymentSchedule.get_overdue_repayment_schedule() return LoanRepaymentSchedule.get_overdue_repayment_schedule()
@@ -18,8 +20,86 @@ class LoanRepaymentScheduleService:
return LoanRepaymentSchedule.get_repayment_schedule_by_transaction_id(transaction_id) return LoanRepaymentSchedule.get_repayment_schedule_by_transaction_id(transaction_id)
@classmethod @classmethod
def update_repayment_schedule_status(cls, schedule_id, paid): def update_repayment_schedule_status(cls, schedule_id):
""" """
Update repayment schedule status. Update repayment schedule status.
""" """
return LoanRepaymentSchedule.update_repayment_schedule_status(schedule_id, paid) 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}")