added repayment_schedule
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
from .transaction_type import TransactionType
|
||||
from .loan_status import LoanStatus
|
||||
from .loan_status import LoanStatus
|
||||
from .repayment_schedule_status import RepaymentScheduleStatus
|
||||
@@ -0,0 +1,5 @@
|
||||
from enum import Enum
|
||||
|
||||
class RepaymentScheduleStatus(str, Enum):
|
||||
PARTIALLY_PAID = "partially_paid"
|
||||
REPAID = "repaid"
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
+7
-2
@@ -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
|
||||
|
||||
@@ -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'<LoanRepaymentSchedule Loan:{self.loan_id} Installment:{self.installment_number}>'
|
||||
|
||||
@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
|
||||
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -106,3 +106,5 @@ class LoanService:
|
||||
Get all overdue loans.
|
||||
"""
|
||||
return Loan.get_overdue_loans()
|
||||
|
||||
|
||||
@@ -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)
|
||||
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}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user