From 84ba0c5c118d628a028ec408a78a0f40b55ecbee Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Fri, 15 Dec 2023 10:51:53 +0530 Subject: [PATCH] feat: Penalty accrual and calculation for loans --- .../loan_management/doctype/loan/loan.json | 5 +- .../doctype/loan_demand/loan_demand.json | 24 ++++- .../doctype/loan_demand/loan_demand.py | 10 +- .../loan_disbursement/loan_disbursement.py | 12 ++- .../loan_interest_accrual.json | 5 +- .../loan_interest_accrual.py | 98 ++++++++++++++++--- .../doctype/loan_repayment/loan_repayment.py | 64 +++--------- .../loan_repayment_detail.json | 5 +- .../loan_repayment_schedule.py | 2 +- lending/overrides/sales_invoice.py | 19 ++++ 10 files changed, 163 insertions(+), 81 deletions(-) create mode 100644 lending/overrides/sales_invoice.py diff --git a/lending/loan_management/doctype/loan/loan.json b/lending/loan_management/doctype/loan/loan.json index a5b4d810..0865422d 100644 --- a/lending/loan_management/doctype/loan/loan.json +++ b/lending/loan_management/doctype/loan/loan.json @@ -553,13 +553,14 @@ "depends_on": "eval: doc.is_term_loan && doc.repayment_schedule_type != \"Line of Credit\"", "fieldname": "moratorium_type", "fieldtype": "Select", - "label": "Moratorium Type" + "label": "Moratorium Type", + "options": "EMI\nInterest" } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-12-06 20:36:53.297695", + "modified": "2023-12-12 15:28:10.241845", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan", diff --git a/lending/loan_management/doctype/loan_demand/loan_demand.json b/lending/loan_management/doctype/loan_demand/loan_demand.json index c40f6a15..5bfec833 100644 --- a/lending/loan_management/doctype/loan_demand/loan_demand.json +++ b/lending/loan_management/doctype/loan_demand/loan_demand.json @@ -20,7 +20,9 @@ "demand_amount", "paid_amount", "waived_amount", - "outstanding_amount" + "outstanding_amount", + "sales_invoice", + "last_repayment_date" ], "fields": [ { @@ -31,17 +33,20 @@ { "fieldname": "demand_date", "fieldtype": "Date", + "in_list_view": 1, "label": "Demand Date" }, { "fieldname": "demand_type", "fieldtype": "Select", + "in_list_view": 1, "label": "Demand Type", "options": "EMI\nPenalty\nNormal\nCharges" }, { "fieldname": "demand_amount", "fieldtype": "Currency", + "in_list_view": 1, "label": "Demand Amount" }, { @@ -67,6 +72,7 @@ { "fieldname": "paid_amount", "fieldtype": "Currency", + "in_list_view": 1, "label": "Paid Amount" }, { @@ -109,6 +115,7 @@ { "fieldname": "demand_subtype", "fieldtype": "Select", + "in_list_view": 1, "label": "Demand Subtype", "options": "Principal\nInterest\nPenalty\nCharges" }, @@ -118,18 +125,30 @@ "fieldtype": "Link", "label": "Company", "options": "Company" + }, + { + "fieldname": "sales_invoice", + "fieldtype": "Link", + "label": "Sales Invoice", + "options": "Sales Invoice" + }, + { + "fieldname": "last_repayment_date", + "fieldtype": "Date", + "label": "Last Repayment Date" } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-12-04 13:07:44.221871", + "modified": "2023-12-13 22:28:15.756117", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Demand", "owner": "Administrator", "permissions": [ { + "cancel": 1, "create": 1, "delete": 1, "email": 1, @@ -139,6 +158,7 @@ "report": 1, "role": "System Manager", "share": 1, + "submit": 1, "write": 1 } ], diff --git a/lending/loan_management/doctype/loan_demand/loan_demand.py b/lending/loan_management/doctype/loan_demand/loan_demand.py index 6ea55d26..ff743f8d 100644 --- a/lending/loan_management/doctype/loan_demand/loan_demand.py +++ b/lending/loan_management/doctype/loan_demand/loan_demand.py @@ -13,8 +13,11 @@ def validate(self): pass def on_submit(self): - self.make_gl_entries() - self.update_repayment_schedule() + if self.demand_subtype in ("Principal", "Interest", "Penalty"): + self.make_gl_entries() + + if self.demand_type == "EMI": + self.update_repayment_schedule() def update_repayment_schedule(self, cancel=0): if self.repayment_schedule_detail: @@ -23,13 +26,14 @@ def update_repayment_schedule(self, cancel=0): ) def on_cancel(self): + self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"] self.make_gl_entries(cancel=1) self.update_repayment_schedule(cancel=1) def make_gl_entries(self, cancel=0): gl_entries = [] - if self.demand_subtype == "Principal": + if self.demand_subtype in ("Principal", "Charges"): return if self.demand_subtype == "Interest": diff --git a/lending/loan_management/doctype/loan_disbursement/loan_disbursement.py b/lending/loan_management/doctype/loan_disbursement/loan_disbursement.py index 905dde43..4335edc2 100644 --- a/lending/loan_management/doctype/loan_disbursement/loan_disbursement.py +++ b/lending/loan_management/doctype/loan_disbursement/loan_disbursement.py @@ -106,12 +106,16 @@ def get_disbursed_amount(self): return disbursed_amount - def update_draft_schedule(self): + def get_draft_schedule(self): draft_schedule = frappe.db.get_value( "Loan Repayment Schedule", {"loan": self.against_loan, "docstatus": 0}, "name" ) + return draft_schedule - if self.repayment_frequency == "Monthly": + def update_draft_schedule(self): + draft_schedule = self.get_draft_schedule() + + if self.repayment_frequency == "Monthly" and not self.repayment_start_date: loan_product = frappe.db.get_value("Loan", self.against_loan, "loan_product") self.repayment_start_date = get_cyclic_date(loan_product, self.posting_date) @@ -306,6 +310,8 @@ def set_status_and_amounts(self, cancel=0): disbursed_amount, status, total_payment, + total_interest_payable, + monthly_repayment_amount, new_available_limit_amount, new_utilized_limit_amount, ) = self.get_values_on_cancel(loan_details) @@ -368,6 +374,8 @@ def get_values_on_cancel(self, loan_details): disbursed_amount, status, total_payment, + 0, + 0, new_available_limit_amount, new_utilized_limit_amount, ) diff --git a/lending/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.json b/lending/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.json index b31c891e..fe92e29c 100644 --- a/lending/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.json +++ b/lending/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.json @@ -45,7 +45,8 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Loan", - "options": "Loan" + "options": "Loan", + "reqd": 1 }, { "fieldname": "posting_date", @@ -245,7 +246,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-11-22 16:57:11.982248", + "modified": "2023-12-11 15:08:17.332488", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Interest Accrual", diff --git a/lending/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py b/lending/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py index 129ec48b..f37a1566 100644 --- a/lending/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py +++ b/lending/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py @@ -12,9 +12,6 @@ class LoanInterestAccrual(AccountsController): def validate(self): - if not self.loan: - frappe.throw(_("Loan is mandatory")) - if not self.posting_date: self.posting_date = nowdate() @@ -139,6 +136,7 @@ def calculate_accrual_amount_for_loans(loan, posting_date, process_loan_interest "posting_date": posting_date, "due_date": posting_date, "accrual_type": accrual_type, + "interest_type": "Normal Interest", } ) @@ -147,6 +145,45 @@ def calculate_accrual_amount_for_loans(loan, posting_date, process_loan_interest generate_loan_demand(loan, posting_date, payable_interest) +def calculate_penal_interest_for_loans(loan, posting_date, process_loan_interest, accrual_type): + from lending.loan_management.doctype.loan_repayment.loan_repayment import get_unpaid_demands + + demands = get_unpaid_demands(loan.name, posting_date) + + loan_product = frappe.get_value("Loan", loan.name, "loan_product") + penal_interest_rate = frappe.get_value("Loan Product", loan_product, "penalty_interest_rate") + penal_interest_amount = 0 + + for demand in demands: + if demand.demand_subtype in ("Principal", "Interest"): + if getdate(demand.demand_date) < getdate(posting_date): + penal_interest_amount += ( + demand.demand_amount + * penal_interest_rate + * date_diff(posting_date, demand.last_repayment_date or demand.demand_date) + / 36500 + ) + + args = frappe._dict( + { + "loan": loan.name, + "applicant_type": loan.applicant_type, + "applicant": loan.applicant, + "interest_income_account": loan.penalty_income_account, + "loan_account": loan.loan_account, + "interest_amount": penal_interest_amount, + "process_loan_interest": process_loan_interest, + "posting_date": posting_date, + "accrual_type": accrual_type, + "interest_type": "Penal Interest", + } + ) + + if penal_interest_amount > 0: + make_loan_interest_accrual_entry(args) + create_loan_demand(loan.name, posting_date, "Penalty", "Penalty", penal_interest_amount) + + def make_accrual_interest_entry_for_loans( posting_date, process_loan_interest=None, @@ -177,6 +214,7 @@ def make_accrual_interest_entry_for_loans( "refund_amount", "loan_account", "interest_income_account", + "penalty_income_account", "loan_amount", "is_term_loan", "status", @@ -194,31 +232,48 @@ def make_accrual_interest_entry_for_loans( filters=query_filters, ) - open_loans += get_term_loans(term_loan=loan, loan_product=loan_product) + open_loans += get_term_loans(term_loan=loan, loan_product=loan_product, posting_date=posting_date) for loan in open_loans: calculate_accrual_amount_for_loans(loan, posting_date, process_loan_interest, accrual_type) + calculate_penal_interest_for_loans(loan, posting_date, process_loan_interest, accrual_type) -def generate_loan_demand(loan, posting_date, payable_interest): - print(loan.is_term_loan, loan.payment_date, getdate(loan.payment_date), getdate(posting_date)) +def generate_loan_demand( + loan, posting_date, payable_interest, demand_subtype=None, demand_type=None +): if not loan.is_term_loan: create_loan_demand(loan.name, posting_date, "Normal", "Interest", payable_interest) - elif ( - loan.is_term_loan - and loan.get("payment_date") - and getdate(loan.get("payment_date")) <= getdate(posting_date) + elif loan.is_term_loan and ( + (loan.get("payment_date") and getdate(loan.get("payment_date")) <= getdate(posting_date)) + or demand_type == "Penalty" ): create_loan_demand( - loan.name, posting_date, "EMI", "Interest", loan.interest_amount, loan.payment_entry + loan.name, + posting_date, + demand_type or "EMI", + demand_subtype or "Interest", + loan.interest_amount, + loan.payment_entry, ) create_loan_demand( - loan.name, posting_date, "EMI", "Principal", loan.principal_amount, loan.payment_entry + loan.name, + posting_date, + demand_type or "EMI", + demand_subtype or "Principal", + loan.principal_amount, + loan.payment_entry, ) def create_loan_demand( - loan, posting_date, demand_type, demand_subtype, amount, repayment_schedule_detail=None + loan, + posting_date, + demand_type, + demand_subtype, + amount, + repayment_schedule_detail=None, + sales_invoice=None, ): demand = frappe.new_doc("Loan Demand") demand.loan = loan @@ -227,6 +282,7 @@ def create_loan_demand( demand.demand_type = demand_type demand.demand_subtype = demand_subtype demand.demand_amount = amount + demand.sales_invoice = sales_invoice demand.save() demand.submit() @@ -270,7 +326,7 @@ def create_loan_demand( # ) -def get_term_loans(term_loan=None, loan_product=None): +def get_term_loans(term_loan=None, loan_product=None, posting_date=None): loan = frappe.qb.DocType("Loan") loan_schedule = frappe.qb.DocType("Loan Repayment Schedule") loan_repayment_schedule = frappe.qb.DocType("Repayment Schedule") @@ -308,7 +364,7 @@ def get_term_loans(term_loan=None, loan_product=None): & (loan.status.isin(["Disbursed", "Partially Disbursed", "Active"])) & (loan.is_term_loan == 1) & (loan_schedule.status == "Active") - & (loan_repayment_schedule.principal_amount > 0) + & (loan_repayment_schedule.total_payment > 0) & (loan_repayment_schedule.demand_generated == 0) & (loan_repayment_schedule.docstatus == 1) ) @@ -323,7 +379,16 @@ def get_term_loans(term_loan=None, loan_product=None): term_loans = query.run(as_dict=1) - return term_loans + considered_loans = [] + filtered_loans = [] + + for loan in term_loans: + if loan.name not in considered_loans: + filtered_loans.append(loan) + considered_loans.append(loan.name) + + print(filtered_loans) + return filtered_loans def make_loan_interest_accrual_entry(args): @@ -348,6 +413,7 @@ def make_loan_interest_accrual_entry(args): loan_interest_accrual.payable_principal_amount = args.payable_principal loan_interest_accrual.accrual_type = args.accrual_type loan_interest_accrual.due_date = args.due_date + loan_interest_accrual.interest_type = args.interest_type loan_interest_accrual.save() loan_interest_accrual.submit() diff --git a/lending/loan_management/doctype/loan_repayment/loan_repayment.py b/lending/loan_management/doctype/loan_repayment/loan_repayment.py index 7a0f389b..67cd46ad 100644 --- a/lending/loan_management/doctype/loan_repayment/loan_repayment.py +++ b/lending/loan_management/doctype/loan_repayment/loan_repayment.py @@ -248,7 +248,9 @@ def update_paid_amounts(self): loan_demand = frappe.qb.DocType("Loan Demand") frappe.qb.update(loan_demand).set( loan_demand.paid_amount, loan_demand.paid_amount + payment.paid_amount - ).where(loan_demand.name == payment.loan_demand).run() + ).set(loan_demand.last_repayment_date, self.posting_date).where( + loan_demand.name == payment.loan_demand + ).run() if self.repayment_type == "Normal Repayment": loan = frappe.qb.DocType("Loan") @@ -280,15 +282,6 @@ def mark_as_unpaid(self): as_dict=1, ) - # no_of_repayments = len(self.repayment_details) - - # loan.update( - # { - # "total_amount_paid": loan.total_amount_paid - self.amount_paid, - # "total_principal_paid": loan.total_principal_paid - self.principal_amount_paid, - # } - # ) - if loan.disbursed_amount >= loan.loan_amount: loan["status"] = "Disbursed" else: @@ -306,12 +299,6 @@ def mark_as_unpaid(self): loan_demand.paid_amount, loan_demand.paid_amount - payment.paid_amount ).where(loan_demand.name == payment.loan_demand).run() - # Cancel repayment interest accrual - # checking idx as a preventive measure, repayment accrual will always be the last entry - # if payment.accrual_type == "Repayment" and payment.idx == no_of_repayments: - # lia_doc = frappe.get_doc("Loan Interest Accrual", payment.loan_interest_accrual) - # lia_doc.cancel() - loan = frappe.qb.DocType("Loan") frappe.qb.update(loan).set( loan.total_amount_paid, loan.total_amount_paid - self.amount_paid @@ -414,17 +401,13 @@ def apply_allocation_order(self, allocation_order, pending_amount, demands): pending_amount, "EMI", ["Interest", "Principal"], demands ) if d.demand_type == "Principal" and pending_amount > 0: - pending_amount = self.allocate_principal_amount( - pending_amount, "Normal", ["Principal"], demands - ) + pending_amount = self.adjust_component(pending_amount, "Normal", ["Principal"], demands) if d.demand_type == "Interest" and pending_amount > 0: - pending_amount = self.allocate_principal_amount( - pending_amount, "Normal", ["Interest"], demands - ) + pending_amount = self.adjust_component(pending_amount, "Normal", ["Interest"], demands) if d.demand_type == "Penalty" and pending_amount > 0: - pending_amount = self.allocate_principal_amount(pending_amount, "Normal", ["Penalty"], demands) + pending_amount = self.adjust_component(pending_amount, "Normal", ["Penalty"], demands) if d.demand_type == "Charges" and pending_amount > 0: - pending_amount = self.allocate_principal_amount(pending_amount, "Normal", ["Charges"], demands) + pending_amount = self.adjust_component(pending_amount, "Normal", ["Charges"], demands) return pending_amount @@ -916,6 +899,7 @@ def get_unpaid_demands(against_loan, posting_date=None): .select( loan_demand.name, loan_demand.demand_date, + loan_demand.last_repayment_date, (loan_demand.demand_amount - loan_demand.paid_amount).as_("demand_amount"), loan_demand.demand_subtype, loan_demand.demand_type, @@ -932,33 +916,6 @@ def get_unpaid_demands(against_loan, posting_date=None): .run(as_dict=1) ) - # unpaid_accrued_entries = frappe.db.sql( - # """ - # SELECT name, due_date, interest_amount - paid_interest_amount as interest_amount, - # payable_principal_amount - paid_principal_amount as payable_principal_amount, - # accrual_type - # FROM - # `tabLoan Interest Accrual` - # WHERE - # loan = %s - # AND due_date <= %s - # AND (interest_amount - paid_interest_amount > 0 OR - # payable_principal_amount - paid_principal_amount > 0) - # AND - # docstatus = 1 - # ORDER BY due_date - # """, - # (against_loan, posting_date), - # as_dict=1, - # ) - - # Skip entries with zero interest amount & payable principal amount - # unpaid_accrued_entries = [ - # d - # for d in unpaid_accrued_entries - # if flt(d.interest_amount, precision) > 0 or flt(d.payable_principal_amount, precision) > 0 - # ] - return loan_demands @@ -1134,6 +1091,9 @@ def get_amounts(amounts, against_loan, posting_date, with_loan_details=False): elif demand.demand_subtype == "Principal": payable_principal_amount += demand.demand_amount interest_demands.append(demand) + elif demand.demand_subtype == "Penalty": + penalty_amount += demand.demand_amount + penal_demands.append(demand) # pending_demands.setdefault( # demand.name, @@ -1169,7 +1129,7 @@ def get_amounts(amounts, against_loan, posting_date, with_loan_details=False): amounts["pending_principal_amount"] = flt(pending_principal_amount, precision) amounts["payable_principal_amount"] = flt(payable_principal_amount, precision) amounts["interest_amount"] = flt(total_pending_interest, precision) - # amounts["penalty_amount"] = flt(penalty_amount + pending_penalty_amount, precision) + amounts["penalty_amount"] = flt(penalty_amount, precision) amounts["payable_amount"] = flt( payable_principal_amount + total_pending_interest + penalty_amount, precision ) diff --git a/lending/loan_management/doctype/loan_repayment_detail/loan_repayment_detail.json b/lending/loan_management/doctype/loan_repayment_detail/loan_repayment_detail.json index 10ac4a7c..9608c634 100644 --- a/lending/loan_management/doctype/loan_repayment_detail/loan_repayment_detail.json +++ b/lending/loan_management/doctype/loan_repayment_detail/loan_repayment_detail.json @@ -13,25 +13,28 @@ { "fieldname": "loan_demand", "fieldtype": "Link", + "in_list_view": 1, "label": "Loan Demand", "options": "Loan Demand" }, { "fieldname": "paid_amount", "fieldtype": "Currency", + "in_list_view": 1, "label": "Paid Amount", "options": "Company:company:default_currency" }, { "fieldname": "demand_type", "fieldtype": "Data", + "in_list_view": 1, "label": "Demand Type" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-12-04 17:57:58.214779", + "modified": "2023-12-13 22:28:26.927893", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Repayment Detail", diff --git a/lending/loan_management/doctype/loan_repayment_schedule/loan_repayment_schedule.py b/lending/loan_management/doctype/loan_repayment_schedule/loan_repayment_schedule.py index 63f545e7..4172d2a8 100644 --- a/lending/loan_management/doctype/loan_repayment_schedule/loan_repayment_schedule.py +++ b/lending/loan_management/doctype/loan_repayment_schedule/loan_repayment_schedule.py @@ -175,7 +175,7 @@ def get_days_and_months(self, payment_date, additional_days, balance_amount): days = date_diff(payment_date, self.posting_date) additional_days = 0 - if additional_days: + if additional_days and not self.moratorium_tenure: self.add_broken_period_interest(balance_amount, additional_days, payment_date) additional_days = 0 elif expected_payment_date == payment_date: diff --git a/lending/overrides/sales_invoice.py b/lending/overrides/sales_invoice.py new file mode 100644 index 00000000..9f8d08f0 --- /dev/null +++ b/lending/overrides/sales_invoice.py @@ -0,0 +1,19 @@ +import frappe + +from lending.loan_management.doctype.loan_interest_accrual.loan_interest_accrual import ( + create_loan_demand, +) + + +def generate_demand(self, method=None): + if self.get("loan"): + create_loan_demand( + self.loan, self.posting_date, "Charges", "Charges", self.grand_total, sales_invoice=self.name + ) + + +def cancel_demand(self, method=None): + if self.get("loan"): + demand = frappe.db.get_value("Loan Demand", {"sales_invoice": self.name}) + if demand: + frappe.get_doc("Loan Demand", demand).cancel()