452 lines
16 KiB
Python
452 lines
16 KiB
Python
from datetime import datetime, timezone
|
|
from sqlalchemy.orm import relationship
|
|
from dateutil.relativedelta import relativedelta
|
|
from datetime import timedelta
|
|
import logging
|
|
from sqlalchemy import and_, or_, not_
|
|
from sqlalchemy.sql import func
|
|
from app.utils.logger import logger
|
|
from app.extensions import db
|
|
from decimal import Decimal, ROUND_HALF_UP
|
|
from datetime import datetime, timezone
|
|
from app.config import settings
|
|
|
|
class Loan(db.Model):
|
|
__tablename__ = "loans"
|
|
|
|
id = db.Column(
|
|
db.Integer,
|
|
primary_key=True,
|
|
autoincrement=True,
|
|
)
|
|
customer_id = db.Column(db.String(50), nullable=False)
|
|
transaction_id = db.Column(db.String(50), nullable=True)
|
|
original_transaction = db.Column(db.String(50), nullable=True)
|
|
account_id = db.Column(db.String(50), nullable=False)
|
|
offer_id = db.Column(db.String(20), nullable=False)
|
|
product_id = db.Column(db.String(20), nullable=True)
|
|
collection_type = db.Column(db.String(20), nullable=True)
|
|
current_loan_amount = db.Column(db.Float, nullable=True)
|
|
initial_loan_amount = db.Column(db.Float, nullable=False)
|
|
default_penalty_fee = db.Column(db.Float, default=0)
|
|
continuous_fee = db.Column(db.Float, default=0)
|
|
upfront_fee = db.Column(db.Float, nullable=True, default=0.0)
|
|
repayment_amount = db.Column(db.Float, nullable=True, default=0.0)
|
|
balance = db.Column(db.Float, nullable=True, default=0.0)
|
|
installment_amount = db.Column(db.Float, nullable=True, default=0.0)
|
|
status = db.Column(db.String(20), default='pending')
|
|
tenor = db.Column(db.Integer, nullable=True)
|
|
due_date = db.Column(db.DateTime, nullable=True)
|
|
created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
|
|
updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
|
eligible_amount = db.Column(db.Float, nullable=True, default=0.0)
|
|
disburse_date = db.Column(db.DateTime, nullable=True)
|
|
disburse_verify = db.Column(db.DateTime, nullable=True)
|
|
disburse_result = db.Column(db.String(10), nullable=True)
|
|
disburse_description = db.Column(db.String(100), nullable=True)
|
|
verify_result = db.Column(db.String(10), nullable=True)
|
|
verify_description = db.Column(db.String(100), nullable=True)
|
|
reference = db.Column(db.String(50), nullable=True)
|
|
total_penal_charge = db.Column(db.Float, default=0.0)
|
|
last_penal_date = db.Column(db.DateTime, nullable=True)
|
|
|
|
customer = relationship(
|
|
"Customer",
|
|
primaryjoin="Customer.id == Loan.customer_id",
|
|
foreign_keys=[customer_id],
|
|
back_populates="loans",
|
|
)
|
|
|
|
loan_charges = relationship(
|
|
"LoanCharge",
|
|
primaryjoin="Loan.id == LoanCharge.loan_id",
|
|
foreign_keys="LoanCharge.loan_id",
|
|
back_populates="loan",
|
|
)
|
|
|
|
def __repr__(self):
|
|
return f"<Loan {self.id}>"
|
|
|
|
def to_dict(self):
|
|
"""
|
|
Convert the Loan object to a dictionary format for JSON serialization.
|
|
"""
|
|
return {
|
|
'debtId': self.id,
|
|
"customerId": self.customer_id,
|
|
'initialLoanAmount': self.initial_loan_amount,
|
|
'currentLoanAmount': self.current_loan_amount,
|
|
'defaultPenaltyFee': self.default_penalty_fee,
|
|
'continuousFee': self.continuous_fee,
|
|
'collectionType': self.collection_type,
|
|
'repaymentAmount':self.repayment_amount,
|
|
'status': self.status,
|
|
'productId': self.product_id,
|
|
'disburseResult': self.disburse_result,
|
|
'disburseDescription': self.disburse_description,
|
|
'verifyResult': self.verify_result,
|
|
'verifyDescription': self.verify_description,
|
|
'transactionId': self.transaction_id,
|
|
'accountId':self.account_id,
|
|
'dueDate': self.due_date.isoformat() if self.due_date else None,
|
|
'loanDate': self.created_at.isoformat() if self.created_at else None,
|
|
'disburseDate': self.disburse_date.isoformat() if self.disburse_date else None,
|
|
'disburseVerify': self.disburse_verify.isoformat() if self.disburse_verify else None,
|
|
'reference': self.reference,
|
|
'balance': self.balance,
|
|
'tenor': self.tenor,
|
|
'totalPenalCharge': self.total_penal_charge,
|
|
'lastPenalDate': self.last_penal_date
|
|
}
|
|
|
|
@classmethod
|
|
def get_loan_by_transaction_id(cls, transaction_id):
|
|
return cls.query.filter_by(transaction_id=transaction_id).first()
|
|
|
|
#update loan status
|
|
@classmethod
|
|
def update_status(cls, loan_id, status):
|
|
loan = cls.query.get(loan_id)
|
|
if loan:
|
|
loan.status = status
|
|
db.session.commit()
|
|
return loan.to_dict()
|
|
else:
|
|
raise ValueError(f"Loan with ID {loan_id} not found.")
|
|
|
|
|
|
|
|
@classmethod
|
|
def get_loan_by_loan_id(cls, loan_id):
|
|
return cls.query.filter_by(id=loan_id).first()
|
|
|
|
@classmethod
|
|
def set_disbursement_date(cls, loan_id, customer_id):
|
|
"""
|
|
Update the disburse date of the loan with the given loan_id.
|
|
"""
|
|
# Retrieve loan
|
|
loan = cls.query.get(loan_id)
|
|
|
|
if not loan:
|
|
raise ValueError(f"Loan with ID {loan_id} does not exist.")
|
|
|
|
# Check if customer_id matches
|
|
if loan.customer_id != customer_id:
|
|
raise ValueError(f"Customer ID {customer_id} does not match the loan's customer ID.")
|
|
|
|
current_time = datetime.now()
|
|
logger.info(f"What is now ======= ==== ==> : {current_time}")
|
|
# Update loan disburse_date
|
|
loan.disburse_date = current_time
|
|
|
|
# Commit changes to database
|
|
try:
|
|
logger.info(f"Updating disburse date for loan ID {loan_id} to {current_time}")
|
|
db.session.commit()
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Failed to update disburse date: {e}")
|
|
raise
|
|
@classmethod
|
|
def set_disburse_verify_date(cls, loan_id, customer_id):
|
|
"""
|
|
Update the disburse verify date of the loan with the given loan_id.
|
|
"""
|
|
# Retrieve loan
|
|
loan = cls.query.get(loan_id)
|
|
|
|
if not loan:
|
|
raise ValueError(f"Loan with ID {loan_id} does not exist.")
|
|
|
|
# Check if customer_id matches
|
|
if loan.customer_id != customer_id:
|
|
raise ValueError(f"Customer ID {customer_id} does not match the loan's customer ID.")
|
|
|
|
current_time = datetime.now()
|
|
logger.info(f"What is now ======= ==== ==> : {current_time}")
|
|
# Update loan verify_date
|
|
loan.disburse_verify = current_time
|
|
|
|
# Commit changes to database
|
|
try:
|
|
logger.info(f"Updating disburse verify date for loan ID {loan_id} to {current_time}")
|
|
db.session.commit()
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Failed to update disburse verify date: {e}")
|
|
raise
|
|
|
|
@classmethod
|
|
def set_disbursement_message(cls, loan_id, description):
|
|
"""
|
|
Update the disburse result and description of the loan with the given loan_id.
|
|
"""
|
|
# Retrieve loan
|
|
loan = cls.query.get(loan_id)
|
|
|
|
if not loan:
|
|
raise ValueError(f"Loan with ID {loan_id} does not exist.")
|
|
|
|
# Update disburse description only
|
|
loan.disburse_description = description
|
|
|
|
# Commit changes to database
|
|
try:
|
|
logger.info(f"Updating disburse result for loan ID {loan_id} with description {description}")
|
|
db.session.commit()
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Failed to update disbursement result: {e}")
|
|
raise
|
|
|
|
|
|
@classmethod
|
|
def set_disbursement_result(cls, loan_id, result, description):
|
|
"""
|
|
Update the disburse result and description of the loan with the given loan_id.
|
|
"""
|
|
# Retrieve loan
|
|
loan = cls.query.get(loan_id)
|
|
|
|
if not loan:
|
|
raise ValueError(f"Loan with ID {loan_id} does not exist.")
|
|
|
|
# Update disburse result and description
|
|
loan.disburse_result = result
|
|
loan.disburse_description = description
|
|
|
|
# Commit changes to database
|
|
try:
|
|
logger.info(f"Updating disburse result for loan ID {loan_id} to {result} with description {description}")
|
|
db.session.commit()
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Failed to update disbursement result: {e}")
|
|
raise
|
|
|
|
|
|
@classmethod
|
|
def set_disburse_verify_result(cls, loan_id, result, description):
|
|
"""
|
|
Update the verify result and description of the loan with the given loan_id.
|
|
"""
|
|
# Retrieve loan
|
|
loan = cls.query.get(loan_id)
|
|
|
|
if not loan:
|
|
raise ValueError(f"Loan with ID {loan_id} does not exist.")
|
|
|
|
# Update disburse result and description
|
|
loan.verify_result = result
|
|
loan.verify_description = description
|
|
|
|
# Commit changes to database
|
|
try:
|
|
logger.info(f"Updating verify result for loan ID {loan_id} to {result} with description {description}")
|
|
db.session.commit()
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Failed to update verify result: {e}")
|
|
raise
|
|
@classmethod
|
|
def get_latest_loan_without_disburse_date(cls):
|
|
"""
|
|
Get the latest loan without a disbursement date.
|
|
"""
|
|
logger.info("Fetching latest loan without disburse date")
|
|
|
|
try:
|
|
return cls.query.filter(
|
|
cls.disburse_date.is_(None)
|
|
).order_by(cls.created_at.desc()).first()
|
|
except Exception as e:
|
|
logger.error(f"Error fetching latest loan without disburse date: {e}")
|
|
raise
|
|
|
|
@classmethod
|
|
def get_failed_disbursements(cls):
|
|
"""
|
|
Get all loans with failed disbursement.
|
|
"""
|
|
try:
|
|
last_time_interval = settings.FAILED_RETRY_TIME_INTERVAL_SECONDS or 0
|
|
failed_loans = cls.query.filter(
|
|
cls.disburse_date.is_(None),
|
|
cls.created_at >= datetime.utcnow() - timedelta(seconds=last_time_interval)
|
|
).all()
|
|
|
|
if not failed_loans:
|
|
logger.info("No failed disbursements found.")
|
|
return []
|
|
|
|
logger.info(f"Found {len(failed_loans)} failed disbursements.")
|
|
return failed_loans
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching failed disbursements: {e}")
|
|
return []
|
|
|
|
@classmethod
|
|
def get_latest_loan_with_disburse_date(cls):
|
|
"""
|
|
Get the latest loan with a disbursement date and no verification date.
|
|
"""
|
|
return cls.query.filter(
|
|
cls.disburse_date.isnot(None),
|
|
cls.disburse_verify.is_(None)
|
|
).order_by(cls.created_at.desc()).first()
|
|
|
|
@classmethod
|
|
def get_customer_loans(cls, customer_id):
|
|
"""
|
|
Get customer's active loans and sum by customer_id.
|
|
"""
|
|
customer_loans = cls.query.filter_by( customer_id = customer_id).all()
|
|
if not customer_loans:
|
|
raise ValueError(f"Customer with Id {customer_id} does not have any loan.")
|
|
|
|
total_amount = (
|
|
cls.query.with_entities(func.coalesce(func.sum(cls.balance), 0.0))
|
|
.filter_by(customer_id=customer_id)
|
|
.scalar()
|
|
)
|
|
|
|
logger.info(f"Found {len(customer_loans)} loans for customer ID: {customer_id} with total amount: {total_amount}")
|
|
|
|
return customer_loans, total_amount
|
|
@classmethod
|
|
def get_customer_active_loans(cls, customer_id):
|
|
"""
|
|
Get customer's active loans and sum by customer_id.
|
|
"""
|
|
customer_loans = cls.query.filter(
|
|
cls.customer_id == customer_id,
|
|
cls.status != 'repaid'
|
|
).all()
|
|
|
|
if not customer_loans:
|
|
raise ValueError(f"Customer with Id {customer_id} does not have any active loan.")
|
|
|
|
total_amount = (
|
|
cls.query
|
|
.with_entities(func.coalesce(func.sum(cls.balance), 0.0))
|
|
.filter(
|
|
cls.customer_id == customer_id,
|
|
cls.status != 'repaid'
|
|
)
|
|
.scalar()
|
|
)
|
|
|
|
logger.info(f"Found {len(customer_loans)} active loans for customer ID: {customer_id} with total amount: {total_amount}")
|
|
|
|
return customer_loans, total_amount
|
|
|
|
|
|
@classmethod
|
|
def update_status(cls, loan_id, status):
|
|
"""
|
|
Update the status of the loan record with the given loan_id.
|
|
"""
|
|
try:
|
|
# Retrieve loan record
|
|
loan = cls.query.get(loan_id)
|
|
|
|
if not loan:
|
|
raise ValueError(f"Loan with ID {loan_id} does not exist.")
|
|
|
|
if loan.status == status:
|
|
return loan.to_dict() # Still return the current state if no change
|
|
|
|
# Update status and timestamp
|
|
loan.status = status
|
|
loan.updated_at = datetime.now(timezone.utc)
|
|
db.session.commit()
|
|
|
|
logger.info("Loan status updated and committed.")
|
|
return loan.to_dict()
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Error updating loan status: {e}")
|
|
raise Exception(f"Error updating loan status: {str(e)}")
|
|
|
|
|
|
@classmethod
|
|
def update_loan_balance(cls, loan_id, amount_collected):
|
|
"""
|
|
Update the balance of a loan after successful repayment.
|
|
"""
|
|
try:
|
|
# Fetch the loan record
|
|
loan = cls.query.get(loan_id)
|
|
|
|
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)
|
|
|
|
# Ensure valid repayment amount
|
|
if amount_collected <= Decimal("0.00"):
|
|
logger.info(f"Repayment amount is less than or equal to 0: {amount_collected}. Must be greater than 0.00")
|
|
if balance <= Decimal("0.00"):
|
|
raise ValueError("There is no balance for this loan.")
|
|
if amount_collected > 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
|
|
loan.balance = float(new_balance)
|
|
loan.updated_at = datetime.now(timezone.utc)
|
|
db.session.commit()
|
|
|
|
logger.info(f"Loan balance updated for loan ID {loan_id}. New balance: {loan.balance}")
|
|
return loan.to_dict()
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger.error(f"Error updating loan balance: {e}")
|
|
raise Exception(f"Error updating loan balance: {str(e)}")
|
|
|
|
@classmethod
|
|
def get_overdue_loans(cls):
|
|
"""
|
|
Get all overdue loans.
|
|
"""
|
|
try:
|
|
overdue_loans = cls.query.filter(
|
|
cls.due_date < datetime.now(timezone.utc),
|
|
cls.status != 'repaid'
|
|
).all()
|
|
|
|
if not overdue_loans:
|
|
logger.info("No overdue loans found.")
|
|
return []
|
|
|
|
logger.info(f"Found {len(overdue_loans)} overdue loans.")
|
|
return overdue_loans
|
|
except Exception as e:
|
|
logger.error(f"Error fetching overdue loans: {e}")
|
|
return []
|
|
|
|
@classmethod
|
|
def apply_penal_to_loan(cls, loan_id, penal_amount):
|
|
|
|
loan = cls.query.get(loan_id)
|
|
|
|
if not loan:
|
|
raise ValueError("Loan not found")
|
|
penal_amount = Decimal(str(penal_amount))
|
|
|
|
loan.total_penal_charge = Decimal(str(loan.total_penal_charge or 0)) + penal_amount
|
|
loan.last_penal_date = datetime.now(timezone.utc)
|
|
|
|
db.session.commit()
|