added repayment_schedule
This commit is contained in:
@@ -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
|
||||||
@@ -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.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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -106,3 +106,5 @@ class LoanService:
|
|||||||
Get all overdue loans.
|
Get all overdue loans.
|
||||||
"""
|
"""
|
||||||
return Loan.get_overdue_loans()
|
return Loan.get_overdue_loans()
|
||||||
|
|
||||||
|
|
||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user