Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions lending/loan_management/doctype/loan/test_loan.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,10 @@ def test_loan_closure(self):

accrued_interest_amount = (loan.loan_amount * loan.rate_of_interest * 34) / (36500)
make_loan_disbursement_entry(loan.name, loan.loan_amount, disbursement_date=first_date)
process_daily_loan_demands(posting_date=add_days(last_date, 5), loan=loan)
process_loan_interest_accrual_for_loans(
posting_date=add_days(last_date, 4), loan=loan.name, company="_Test Company"
)
process_daily_loan_demands(posting_date=add_days(last_date, 5), loan=loan.name)
repayment_entry = create_repayment_entry(
loan.name,
add_days(last_date, 5),
Expand Down Expand Up @@ -732,9 +735,11 @@ def test_loan_write_off_limit(self):
loan = create_secured_demand_loan(self.applicant2)
self.assertEqual(loan.loan_amount, 1000000)
repayment_date = "2019-11-01"
no_of_days = date_diff(repayment_date, "2019-10-01")
# no_of_days = 34

accrued_interest_amount = (loan.loan_amount * loan.rate_of_interest * 31) / (36500)
process_loan_interest_accrual_for_loans(
posting_date=add_days("2019-11-01", -1), loan=loan.name, company="_Test Company"
)
process_daily_loan_demands(posting_date="2019-11-01", loan=loan.name)
# repay 50 less so that it can be automatically written off
repayment_entry = create_repayment_entry(
Expand Down Expand Up @@ -1342,6 +1347,8 @@ def test_full_settlement(self):
loan.name, "2024-08-05", 1000000, repayment_type="Full Settlement"
)
repayment_entry.submit()
loan.load_from_db()
self.assertEqual(loan.status, "Settled")

def test_backdated_pre_payment(self):
loan = create_loan(
Expand Down Expand Up @@ -1944,6 +1951,7 @@ def create_loan_product(
charges_receivable_account="Charges Receivable - _TC",
suspense_interest_income="Suspense Income Account - _TC",
interest_waiver_account="Interest Waiver Account - _TC",
write_off_account="Write Off Account - _TC",
repayment_method=None,
repayment_periods=None,
repayment_schedule_type="Monthly as per repayment start date",
Expand Down Expand Up @@ -1991,6 +1999,7 @@ def create_loan_product(
loan_product_doc.interest_waiver_account = interest_waiver_account
loan_product_doc.interest_accrued_account = interest_accrued_account
loan_product_doc.penalty_accrued_account = penalty_accrued_account
loan_product_doc.write_off_account = write_off_account
loan_product_doc.broken_period_interest_recovery_account = broken_period_interest_recovery_account
loan_product_doc.additional_interest_income = additional_interest_income
loan_product_doc.additional_interest_accrued = additional_interest_accrued
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
from lending.loan_management.doctype.loan_repayment_schedule.loan_repayment_schedule import (
get_monthly_repayment_amount,
)
from lending.loan_management.doctype.loan_repayment_schedule.utils import (
add_single_month,
get_ceil_monthly_repayment,
)
from lending.loan_management.doctype.loan_security_price.loan_security_price import (
get_loan_security_price,
)
Expand Down Expand Up @@ -115,8 +119,19 @@ def get_repayment_details(self):

if self.is_term_loan:
if self.repayment_method == "Repay Over Number of Periods":
ceil_monthly_repayment = get_ceil_monthly_repayment(loan_product=self.loan_product)
interest_day_count_convention = frappe.get_cached_value(
"Company", self.company, "interest_day_count_convention"
)
self.repayment_amount = get_monthly_repayment_amount(
self.loan_amount, self.rate_of_interest, self.repayment_periods, "Monthly"
self.loan_amount,
self.rate_of_interest,
self.repayment_periods,
"Monthly",
ceil_monthly_repayment=ceil_monthly_repayment,
interest_day_count_convention=interest_day_count_convention,
disbursement_date=self.posting_date,
repayment_start_date=add_single_month(self.posting_date),
)

if self.repayment_method == "Repay Fixed Amount per Period":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,6 @@ def make_loan_demand_for_demand_loans(
loan=None,
process_loan_demand=None,
):
precision = cint(frappe.db.get_default("currency_precision")) or 2
filters = {
"docstatus": 1,
"status": ("in", ("Disbursed", "Partially Disbursed", "Active")),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
cint,
date_diff,
flt,
get_datetime,
get_first_day,
get_first_day_of_week,
get_last_day,
getdate,
nowdate,
)
Expand Down Expand Up @@ -264,11 +264,11 @@ def calculate_accrual_amount_for_loans(
pending_principal_amount = get_pending_principal_amount(loan)

payable_interest = get_interest_amount(
no_of_days,
principal_amount=pending_principal_amount,
rate_of_interest=loan.rate_of_interest,
from_date=last_accrual_date,
to_date=posting_date,
company=loan.company,
posting_date=posting_date,
)

if payable_interest > 0:
Expand Down Expand Up @@ -394,11 +394,11 @@ def is_posting_date_accrual_day(loan_accrual_frequency, posting_date):
def get_interest_for_term(company, rate_of_interest, pending_principal_amount, from_date, to_date):
no_of_days = date_diff(to_date, from_date) + 1
payable_interest = get_interest_amount(
no_of_days,
principal_amount=pending_principal_amount,
rate_of_interest=rate_of_interest,
from_date=from_date,
to_date=add_days(to_date, 1),
company=company,
posting_date=to_date,
)

return payable_interest
Expand Down Expand Up @@ -557,10 +557,15 @@ def calculate_penal_interest_for_loans(
else:
from_date = add_days(last_accrual_date, 1)

no_of_days = date_diff(posting_date, from_date)

penal_interest_amount = flt(demand.pending_amount) * penal_interest_rate * no_of_days / 36500
# no_of_days = date_diff(posting_date, from_date)

penal_interest_amount = get_interest_amount(
principal_amount=demand.pending_amount,
rate_of_interest=penal_interest_amount,
from_date=from_date,
to_date=posting_date,
company=loan.company,
)
if flt(penal_interest_amount, precision) > 0:
total_penal_interest += penal_interest_amount

Expand All @@ -578,10 +583,13 @@ def calculate_penal_interest_for_loans(
if not principal_amount:
continue

per_day_interest = get_per_day_interest(
principal_amount, loan.rate_of_interest, loan.company, posting_date
additional_interest_amount = get_interest_amount(
principal_amount=principal_amount,
rate_of_interest=loan.rate_of_interest,
from_date=from_date,
to_date=posting_date,
company=loan.company,
)
additional_interest = flt(per_day_interest * no_of_days, precision)

if not is_future_accrual:
if flt(penal_interest_amount, precision) > 0:
Expand All @@ -596,7 +604,7 @@ def calculate_penal_interest_for_loans(
"Penal Interest",
penal_interest_rate,
loan_demand=demand.name,
additional_interest=additional_interest,
additional_interest=additional_interest_amount,
accrual_date=accrual_date,
loan_repayment_schedule_detail=demand.repayment_schedule_detail,
)
Expand Down Expand Up @@ -872,49 +880,75 @@ def days_in_year(year):
return days


def get_per_day_interest(
principal_amount, rate_of_interest, company, posting_date=None, interest_day_count_convention=None
def get_interest_amount(
principal_amount,
rate_of_interest,
from_date,
to_date,
company=None,
interest_day_count_convention=None,
):
if not posting_date:
posting_date = getdate()

if not interest_day_count_convention:
interest_day_count_convention = frappe.get_cached_value(
"Company", company, "interest_day_count_convention"
)

if interest_day_count_convention == "Actual/365" or interest_day_count_convention == "30/365":
year_divisor = 365
elif interest_day_count_convention == "30/360" or interest_day_count_convention == "Actual/360":
year_divisor = 360
interest_for_duration = rate_of_interest / 100
if interest_day_count_convention == "Actual/Actual":
year_a = getdate(from_date)
year_b = getdate(to_date)
if days_in_year(year_a) != days_in_year(year_b):
first_year_interest_rate = date_diff(getdate(f"31-12-{year_a}"), from_date) / days_in_year(
year_a
)
second_year_interest_rate = date_diff(to_date, getdate(f"31-12-{year_a}")) / days_in_year(
year_b
)
interest_for_duration *= first_year_interest_rate + second_year_interest_rate
else:
interest_for_duration *= date_diff(getdate(to_date, from_date)) / days_in_year(year_a)
else:
# Default is Actual/Actual
year_divisor = days_in_year(get_datetime(posting_date).year)
if interest_day_count_convention.startswith("Actual"):
interest_for_duration *= date_diff(getdate(to_date, from_date))
elif interest_day_count_convention.startswith("30"):
# complex logic; don't even try
# tldr; treats all months as 30 days long
no_of_months = 0
cur_date = from_date
while True:
if add_single_month(cur_date) <= to_date:
no_of_months += 1
cur_date = add_single_month(cur_date)
elif cur_date <= to_date:
last_day_of_cur_date = get_last_day(cur_date)
last_day_of_to_date = get_last_day(to_date)
if last_day_of_cur_date == last_day_of_to_date:
no_of_months += date_diff(to_date, cur_date) / get_days_in_month(cur_date)
else:
no_of_months += date_diff(last_day_of_cur_date, cur_date) / get_days_in_month(cur_date)
no_of_months += date_diff(to_date, last_day_of_cur_date) / get_days_in_month(to_date)
break
else:
break
interest_for_duration *= no_of_months * 30

return flt((principal_amount * rate_of_interest) / (year_divisor * 100))
if interest_day_count_convention.endswith("360"):
interest_for_duration /= 360
if interest_day_count_convention.endswith("365"):
interest_for_duration /= 365

return interest_for_duration * principal_amount

def get_interest_amount(
no_of_days,
principal_amount=None,
rate_of_interest=None,
company=None,
posting_date=None,
interest_per_day=None,
):
interest_day_count_convention = frappe.get_cached_value(
"Company", company, "interest_day_count_convention"
)

if not interest_per_day:
interest_per_day = get_per_day_interest(
principal_amount, rate_of_interest, company, posting_date, interest_day_count_convention
)
def get_days_in_month(date):
return date_diff(get_last_day(date), get_first_day(date)) + 1

if interest_day_count_convention == "30/365" or interest_day_count_convention == "30/360":
no_of_days = 30

return interest_per_day * no_of_days
def add_single_month(date):
if getdate(date) == get_last_day(date):
return get_last_day(add_months(date, 1))
else:
return add_months(date, 1)


def reverse_loan_interest_accruals(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"is_term_loan",
"validate_normal_repayment",
"disabled",
"ceil_monthly_repayment",
"limits_section",
"min_days_bw_disbursement_first_repayment",
"excess_amount_acceptance_limit",
Expand Down Expand Up @@ -513,11 +514,17 @@
"fieldname": "accounting_tab",
"fieldtype": "Tab Break",
"label": "Accounting"
},
{
"default": "1",
"fieldname": "ceil_monthly_repayment",
"fieldtype": "Check",
"label": "Ceil Monthly Repayment"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-02-09 16:16:25.371953",
"modified": "2025-02-14 17:27:05.719913",
"modified_by": "Administrator",
"module": "Loan Management",
"name": "Loan Product",
Expand Down
30 changes: 19 additions & 11 deletions lending/loan_management/doctype/loan_repayment/loan_repayment.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,10 @@ def on_submit(self):
update_installment_counts(self.against_loan, loan_disbursement=self.loan_disbursement)

if self.repayment_type == "Full Settlement":
frappe.enqueue(self.post_write_off_settlements, enqueue_after_commit=True)
if not frappe.flags.in_test:
frappe.enqueue(self.post_write_off_settlements, enqueue_after_commit=True)
else:
self.post_write_off_settlements()

update_loan_securities_values(self.against_loan, self.principal_amount_paid, self.doctype)
self.create_loan_limit_change_log()
Expand Down Expand Up @@ -783,16 +786,21 @@ def update_paid_amounts(self):
query = query.set(loan.settlement_date, self.posting_date)
self.update_repayment_schedule_status()

elif self.auto_close_loan() and self.repayment_type in (
"Normal Repayment",
"Pre Payment",
"Advance Payment",
"Security Deposit Adjustment",
"Loan Closure",
"Principal Adjustment",
"Penalty Waiver",
"Interest Waiver",
"Charges Waiver",
elif (
self.auto_close_loan()
and self.repayment_type
in (
"Normal Repayment",
"Pre Payment",
"Advance Payment",
"Security Deposit Adjustment",
"Loan Closure",
"Principal Adjustment",
"Penalty Waiver",
"Interest Waiver",
"Charges Waiver",
)
and not self.is_write_off_waiver
):
if self.repayment_schedule_type != "Line of Credit":
query = query.set(loan.status, "Closed")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"repayment_frequency",
"repayment_start_date",
"maturity_date",
"ceil_monthly_repayment",
"repayment_schedule_section",
"repayment_schedule",
"term_details_section",
Expand Down Expand Up @@ -376,12 +377,19 @@
"fieldname": "co_lending_tab",
"fieldtype": "Tab Break",
"label": "Co Lending"
},
{
"default": "0",
"fieldname": "ceil_monthly_repayment",
"fieldtype": "Check",
"label": "Ceil Monthly Repayment",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-02-09 16:31:11.170120",
"modified": "2025-02-14 16:34:47.294351",
"modified_by": "Administrator",
"module": "Loan Management",
"name": "Loan Repayment Schedule",
Expand Down
Loading