added repayment_schedule #43

Merged
ameye merged 1 commits from test into master 2025-08-22 10:48:59 +00:00
4 changed files with 237 additions and 75 deletions
+48 -24
View File
@@ -2,6 +2,7 @@ import requests
from app.config import settings
from app.helpers.response_helper import ResponseHelper
from app.services.loan import LoanService
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
@@ -323,39 +324,62 @@ class SimbrellaClient:
logger.info(f"Repayment data added: {new_repayment_data.to_dict()}")
else:
logger.warning("Failed to add repayment data")
updated_loan = None
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}")
if loan.balance is None or loan.balance <= 0:
logger.warning(f"Loan ID {loan.id} has no balance. Skipping loan update.")
return ResponseHelper.error("Loan has no balance. Skipping.")
updated_loan = loan.to_dict()
#return ResponseHelper.error("Loan has no balance. Skipping.")
else:
try:
logger.info(f"Updating loan balance for loan ID {loan_data['debtId']} with amount collected: {amount_collected}")
updated_loan = LoanService.update_loan_balance(int(loan_data['debtId']), amount_collected)
logger.info(f"Updated loan: {updated_loan}")
except Exception as ex:
db.session.rollback()
logger.error(f"Error updating loan balance for loan ID {loan.id}: {ex}")
return ResponseHelper.error("Error updating loan balance")
try:
logger.info(f"Updating loan balance for loan ID {loan_data['debtId']} with amount collected: {amount_collected}")
updated_loan = LoanService.update_loan_balance(int(loan_data['debtId']), amount_collected)
logger.info(f"Updated loan: {updated_loan}")
except Exception as ex:
db.session.rollback()
logger.error(f"Error updating loan balance for loan ID {loan.id}: {ex}")
return ResponseHelper.error("Error updating loan balance")
try:
updated_balance = Decimal(str(updated_loan['balance'])).quantize(Decimal('0.01'))
logger.info(f"Updated balance: {updated_balance}")
try:
updated_balance = Decimal(str(updated_loan['balance'])).quantize(Decimal('0.01'))
logger.info(f"Updated balance: {updated_balance}")
if updated_balance <= Decimal('0.00'):
logger.info('Loan fully repaid')
repaid = LoanService.update_status(updated_loan['debtId'], LoanStatus.REPAID)
logger.info(f'Updated loan with repaid status: {repaid}')
else:
logger.info('Loan partially repaid')
partial = LoanService.update_status(updated_loan['debtId'], LoanStatus.ACTIVE_PARTIAL)
logger.info(f'Updated loan with partial status: {partial}')
except Exception as e:
db.session.rollback()
logger.error(f"Error while updating loan status for debtId {updated_loan['debtId']}: {e}")
if updated_balance <= Decimal('0.00'):
logger.info('Loan fully repaid')
repaid = LoanService.update_status(updated_loan['debtId'], LoanStatus.REPAID)
logger.info(f'Updated loan with repaid status: {repaid}')
updated_loan = repaid
else:
logger.info('Loan partially repaid')
partial = LoanService.update_status(updated_loan['debtId'], LoanStatus.ACTIVE_PARTIAL)
logger.info(f'Updated loan with partial status: {partial}')
updated_loan = partial
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
# 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')
return ResponseHelper.success(result, "Successful")
+104
View File
@@ -0,0 +1,104 @@
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
class LoanRepaymentSchedule(db.Model):
__tablename__ = 'loan_repayment_schedules'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
loan_id = db.Column(db.Integer, nullable=False)
transaction_id = db.Column(db.String(50), nullable=True)
product_id = db.Column(db.String(20), nullable=True)
installment_number = db.Column(db.Integer, nullable=False)
due_date = db.Column(db.DateTime, nullable=False)
installment_amount= db.Column(db.Float, default=0.0)
total_repayment_amount = db.Column(db.Float, default=0.0)
paid = db.Column(db.Boolean, default=False)
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)
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))
def to_dict(self):
return {
'id': self.id,
'loan_id': self.loan_id,
'product_id': self.product_id,
'transaction_id': self.transaction_id,
'installment_number': self.installment_number,
'due_date': self.due_date.isoformat() if self.due_date else None,
'installment_amount': self.installment_amount,
'total_repayment_amount': self.total_repayment_amount,
'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_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
}
def __repr__(self):
return f'<LoanRepaymentSchedule Loan:{self.loan_id} Installment:{self.installment_number}>'
@classmethod
def get_repayment_schedule_by_loan_id(cls, loan_id):
"""
Get repayment schedule by loan ID
"""
try:
return cls.query.filter_by(loan_id=loan_id).all()
except Exception as e:
logger.error(f"Error fetching repayment schedule for loan_id={loan_id}: {e}")
return []
@classmethod
def get_repayment_schedule_by_transaction_id(cls, transaction_id):
"""
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):
"""
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.
"""
try:
# Fetch the repayment schedule by ID
schedule = cls.query.get(schedule_id)
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)
# Commit changes to the database
db.session.commit()
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
except Exception as e:
# Catch any other unexpected error
logger.error(f"Unexpected error updating repayment schedule {schedule_id}: {e}")
return None
+66 -51
View File
@@ -257,68 +257,83 @@ def report():
logger.error(f"Error generating or sending report: {e}")
return ResponseHelper.error("Failed to send report", status_code=500, error=str(e))
@autocall_bp.route("/overdue-loans", methods=["GET"])
def overdue_loans():
try:
# Step 1: Get all overdue loans
loans = LoanService.get_overdue_loans()
logger.info(f"Found {len(loans)} overdue loans.")
if not loans:
return ResponseHelper.success(message="No overdue loans found", status_code=200)
# Step 2: Create repayments for each loan
# Step 2: Process each loan
for loan in loans:
logger.info(f"Processing Loan ID: {loan.id}")
try:
repayment_data = {
"customerId": loan.customer_id,
"loanId": loan.id,
"productId": loan.product_id,
"transactionId": loan.transaction_id,
"initiatedBy": "SYSTEM", #To be reviewed
"salaryAmount": 0,
"LoanStatus": loan.status,
}
process_overdue_loan(loan)
logger.info(f"Creating repayment with data: {repayment_data}")
repayment = RepaymentService.create_repayment(repayment_data)
if not repayment or isinstance(repayment, dict) and "error" in repayment:
db.session.rollback() # important in case create_repayment failed mid-way
logger.error(f"Repayment creation failed for loan ID {loan.id}: {repayment}")
continue
try:
LoanService.update_status(loan_id=loan.id, status=LoanStatus.START_REPAY)
except Exception as e:
db.session.rollback()
logger.error(f"Failed to update loan status for loan ID {loan.id}: {e}")
logger.info(f"Created repayment ID: {repayment.id}")
# Step 3: Call Simbrella
try:
simbrella_response = SimbrellaClient.collect_loan_user_due_payment(repayment.to_dict())
if isinstance(simbrella_response, tuple):
simbrella_response, status_code = simbrella_response
logger.warning(f"Simbrella returned tuple: status={status_code}, response={simbrella_response}")
if isinstance(simbrella_response, dict):
if simbrella_response.get("status") != "success":
logger.warning(f"Simbrella failed for repayment ID {repayment.id}: {simbrella_response}")
else:
logger.warning(f"Unexpected Simbrella response: {type(simbrella_response)}")
except Exception as e:
logger.error(f"Failed to call Simbrella for repayment ID {repayment.id}: {e}")
except Exception as e:
db.session.rollback()
logger.error(f"Error creating repayment for loan ID {loan.id}: {e}")
continue
logger.info(f"Finished processing loan ID: {loan.id}")
return ResponseHelper.success(message="Processed overdue loans successfully", status_code=200)
except Exception as e:
logger.error(f"Error fetching overdue loans: {e}")
return ResponseHelper.error("Failed to fetch overdue loans", status_code=500, error=str(e))
def process_overdue_loan(loan):
"""
Handles repayment creation, loan status update, and Simbrella call
for a single overdue loan.
"""
logger.info(f"Processing Loan ID: {loan.id}")
try:
repayment_data = {
"customerId": loan.customer_id,
"loanId": loan.id,
"productId": loan.product_id,
"transactionId": loan.transaction_id,
"initiatedBy": "SYSTEM", # To be reviewed
"salaryAmount": 0,
"LoanStatus": loan.status,
}
logger.info(f"Creating repayment with data: {repayment_data}")
repayment = RepaymentService.create_repayment(repayment_data)
if not repayment or (isinstance(repayment, dict) and "error" in repayment):
db.session.rollback() # important in case create_repayment failed mid-way
logger.error(f"Repayment creation failed for loan ID {loan.id}: {repayment}")
return
# Update loan status
try:
LoanService.update_status(loan_id=loan.id, status=LoanStatus.START_REPAY)
except Exception as e:
db.session.rollback()
logger.error(f"Failed to update loan status for loan ID {loan.id}: {e}")
logger.info(f"Created repayment ID: {repayment.id}")
# Step 3: Call Simbrella
try:
simbrella_response = SimbrellaClient.collect_loan_user_due_payment(repayment.to_dict())
if isinstance(simbrella_response, tuple):
simbrella_response, status_code = simbrella_response
logger.warning(f"Simbrella returned tuple: status={status_code}, response={simbrella_response}")
if isinstance(simbrella_response, dict):
if simbrella_response.get("status") != "success":
logger.warning(f"Simbrella failed for repayment ID {repayment.id}: {simbrella_response}")
else:
logger.warning(f"Unexpected Simbrella response: {type(simbrella_response)}")
except Exception as e:
logger.error(f"Failed to call Simbrella for repayment ID {repayment.id}: {e}")
except Exception as e:
db.session.rollback()
logger.error(f"Error creating repayment for loan ID {loan.id}: {e}")
finally:
logger.info(f"Finished processing loan ID: {loan.id}")
+19
View File
@@ -0,0 +1,19 @@
from app.models.loan_repayment_schedule import LoanRepaymentSchedule
class LoanRepaymentScheduleService:
@classmethod
def get_repayment_schedule_by_loan_id(cls, loan_id):
return LoanRepaymentSchedule.get_repayment_schedule_by_loan_id(loan_id)
@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):
"""
Update repayment schedule status.
"""
return LoanRepaymentSchedule.update_repayment_schedule_status(schedule_id, paid)