diff --git a/.github/helper/install.sh b/.github/helper/install.sh index 125c8d94..4b8651f1 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -4,8 +4,9 @@ set -e cd ~ || exit -sudo apt-get update -sudo apt-get -y install redis-server libcups2-dev -qq +sudo apt update +sudo apt remove mysql-server mysql-client +sudo apt install libcups2-dev redis-server mariadb-client-10.6 pip install frappe-bench @@ -15,15 +16,14 @@ bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frapp mkdir ~/frappe-bench/sites/test_site cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config.json" ~/frappe-bench/sites/test_site/ -mysql --host 127.0.0.1 --port 3306 -u root -e "SET GLOBAL character_set_server = 'utf8mb4'" -mysql --host 127.0.0.1 --port 3306 -u root -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" +mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" +mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" -mysql --host 127.0.0.1 --port 3306 -u root -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'" -mysql --host 127.0.0.1 --port 3306 -u root -e "CREATE DATABASE test_frappe" -mysql --host 127.0.0.1 --port 3306 -u root -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'" +mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'" +mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe" +mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'" -mysql --host 127.0.0.1 --port 3306 -u root -e "UPDATE mysql.user SET Password=PASSWORD('travis') WHERE User='root'" -mysql --host 127.0.0.1 --port 3306 -u root -e "FLUSH PRIVILEGES" +mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "FLUSH PRIVILEGES" install_whktml() { wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz diff --git a/.github/helper/site_config.json b/.github/helper/site_config.json index 28847e40..21fdbf31 100644 --- a/.github/helper/site_config.json +++ b/.github/helper/site_config.json @@ -9,7 +9,7 @@ "mail_password": "test", "admin_password": "admin", "root_login": "root", - "root_password": "travis", + "root_password": "root", "host_name": "http://test_site:8000", "install_apps": ["payments", "erpnext"], "throttle_user_limit": 100 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45421ff4..6c564a87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,16 +36,16 @@ jobs: strategy: fail-fast: false - name: Server + name: Python Unit Tests services: mysql: - image: mariadb:10.3 + image: mariadb:10.6 env: - MYSQL_ALLOW_EMPTY_PASSWORD: YES + MARIADB_ROOT_PASSWORD: 'root' ports: - 3306:3306 - options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 + options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3 steps: - name: Clone diff --git a/lending/loan_management/doctype/loan/loan.json b/lending/loan_management/doctype/loan/loan.json index 7a4c95d3..4c175382 100644 --- a/lending/loan_management/doctype/loan/loan.json +++ b/lending/loan_management/doctype/loan/loan.json @@ -34,6 +34,8 @@ "monthly_repayment_amount", "repayment_start_date", "repayment_frequency", + "moratorium_tenure", + "treatment_of_interest", "is_term_loan", "loan_classification_details_section", "days_past_due", @@ -158,7 +160,6 @@ "fieldname": "rate_of_interest", "fieldtype": "Percent", "label": "Rate of Interest (%) / Year", - "read_only": 1, "reqd": 1 }, { @@ -479,14 +480,6 @@ "fieldtype": "Section Break", "label": "Loan Classification Details" }, - { - "fetch_from": "loan_type.loan_category", - "fieldname": "loan_category", - "fieldtype": "Link", - "label": "Loan Category", - "options": "Loan Category", - "read_only": 1 - }, { "fieldname": "repayment_frequency", "fieldtype": "Select", @@ -503,12 +496,25 @@ "fieldname": "loan_charges_section", "fieldtype": "Section Break", "label": "Loan Charges" + }, + { + "depends_on": "moratorium_tenure", + "fieldname": "treatment_of_interest", + "fieldtype": "Select", + "label": "Treatment of Interest", + "mandatory_depends_on": "moratorium_tenure", + "options": "Capitalize\nAdd to first repayment" + }, + { + "fieldname": "moratorium_tenure", + "fieldtype": "Int", + "label": "Moratorium Tenure" } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-10-13 19:15:47.245190", + "modified": "2023-10-27 10:03:51.322866", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan", diff --git a/lending/loan_management/doctype/loan/loan.py b/lending/loan_management/doctype/loan/loan.py index 148990c9..cd19fcd9 100644 --- a/lending/loan_management/doctype/loan/loan.py +++ b/lending/loan_management/doctype/loan/loan.py @@ -10,6 +10,7 @@ from frappe.query_builder.functions import Sum from frappe.utils import ( add_days, + add_months, cint, date_diff, flt, @@ -94,6 +95,9 @@ def set_cyclic_date(self): self.repayment_start_date = cyclic_date + if self.moratorium_tenure: + self.repayment_start_date = add_months(self.repayment_start_date, self.moratorium_tenure) + def set_default_charge_account(self): for charge in self.get("loan_charges"): if not charge.account: @@ -170,6 +174,8 @@ def make_draft_schedule(self): "rate_of_interest": self.rate_of_interest, "posting_date": self.posting_date, "repayment_frequency": self.repayment_frequency, + "moratorium_tenure": self.moratorium_tenure, + "treatment_of_interest": self.treatment_of_interest, } ).insert() @@ -190,6 +196,8 @@ def update_draft_schedule(self): "loan_amount": self.loan_amount, "monthly_repayment_amount": self.monthly_repayment_amount, "repayment_frequency": self.repayment_frequency, + "moratorium_tenure": self.moratorium_tenure, + "treatment_of_interest": self.treatment_of_interest, } ) schedule.save() @@ -414,11 +422,6 @@ def close_unsecured_term_loan(loan): frappe.throw(_("Cannot close this loan until full repayment")) -def close_loan(loan, total_amount_paid): - frappe.db.set_value("Loan", loan, "total_amount_paid", total_amount_paid) - frappe.db.set_value("Loan", loan, "status", "Closed") - - @frappe.whitelist() def make_loan_disbursement( loan, diff --git a/lending/loan_management/doctype/loan_disbursement_charge/loan_disbursement_charge.json b/lending/loan_management/doctype/loan_disbursement_charge/loan_disbursement_charge.json index fea8318b..b838dbb8 100644 --- a/lending/loan_management/doctype/loan_disbursement_charge/loan_disbursement_charge.json +++ b/lending/loan_management/doctype/loan_disbursement_charge/loan_disbursement_charge.json @@ -35,7 +35,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-10-11 19:47:07.918342", + "modified": "2023-10-12 19:47:07.918342", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Disbursement Charge", diff --git a/lending/loan_management/doctype/loan_repayment/loan_repayment.json b/lending/loan_management/doctype/loan_repayment/loan_repayment.json index 8970fccd..a363447e 100644 --- a/lending/loan_management/doctype/loan_repayment/loan_repayment.json +++ b/lending/loan_management/doctype/loan_repayment/loan_repayment.json @@ -386,7 +386,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-10-11 17:45:09.856637", + "modified": "2023-10-12 17:45:09.856637", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Repayment", diff --git a/lending/loan_management/doctype/loan_repayment_schedule/loan_repayment_schedule.json b/lending/loan_management/doctype/loan_repayment_schedule/loan_repayment_schedule.json index e6873452..29e6461e 100644 --- a/lending/loan_management/doctype/loan_repayment_schedule/loan_repayment_schedule.json +++ b/lending/loan_management/doctype/loan_repayment_schedule/loan_repayment_schedule.json @@ -18,10 +18,13 @@ "loan_product", "repayment_frequency", "repayment_schedule_type", + "repayment_date_on", "repayment_method", "repayment_periods", "monthly_repayment_amount", "repayment_start_date", + "moratorium_tenure", + "treatment_of_interest", "section_break_6rpg", "repayment_schedule", "status", @@ -150,12 +153,31 @@ "fieldtype": "Select", "label": "Repayment Frequency", "options": "Monthly\nDaily\nWeekly\nQuarterly\nOne Time" + }, + { + "fieldname": "treatment_of_interest", + "fieldtype": "Select", + "label": "Treatment Of Interest", + "options": "Capitalize\nAdd to first repayment" + }, + { + "depends_on": "eval:doc.repayment_schedule_type==\"Pro-rated calendar months\"", + "fetch_from": "loan_product.repayment_date_on", + "fieldname": "repayment_date_on", + "fieldtype": "Select", + "label": "Repayment Date On", + "options": "Start of the next month\nEnd of the current month" + }, + { + "fieldname": "moratorium_tenure", + "fieldtype": "Int", + "label": "Moratorium Tenure" } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-10-13 16:55:43.615976", + "modified": "2023-10-26 10:08:14.519533", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Repayment Schedule", 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 ca56501c..51a0a388 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 @@ -5,7 +5,7 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import add_days, add_months, date_diff, flt, get_last_day, getdate +from frappe.utils import add_days, add_months, cint, date_diff, flt, get_last_day, getdate class LoanRepaymentSchedule(Document): @@ -31,30 +31,49 @@ def make_repayment_schedule(self): if not self.repayment_start_date: frappe.throw(_("Repayment Start Date is mandatory for term loans")) - schedule_type_details = frappe.db.get_value( - "Loan Product", self.loan_product, ["repayment_schedule_type", "repayment_date_on"], as_dict=1 - ) - self.repayment_schedule = [] payment_date = self.repayment_start_date balance_amount = self.loan_amount broken_period_interest_days = date_diff(add_months(payment_date, -1), self.posting_date) carry_forward_interest = self.adjusted_interest + moratorium_interest = 0 + + if self.moratorium_tenure: + payment_date = add_months(self.repayment_start_date, -1 * self.moratorium_tenure) + moratorium_end_date = add_months(self.repayment_start_date, -1) + broken_period_interest_days = date_diff(add_months(payment_date, -1), self.posting_date) + 1 while balance_amount > 0: + if self.moratorium_tenure and getdate(payment_date) > getdate(moratorium_end_date): + if self.treatment_of_interest == "Capitalize" and moratorium_interest: + balance_amount = self.loan_amount + moratorium_interest + self.monthly_repayment_amount = get_monthly_repayment_amount( + balance_amount, self.rate_of_interest, self.repayment_periods + ) + moratorium_interest = 0 + interest_amount, principal_amount, balance_amount, total_payment, days = self.get_amounts( payment_date, balance_amount, - self.repayment_frequency, - schedule_type_details.repayment_schedule_type, - schedule_type_details.repayment_date_on, broken_period_interest_days, carry_forward_interest, ) - if schedule_type_details.repayment_schedule_type == "Pro-rated calendar months": + if self.moratorium_tenure: + if getdate(payment_date) <= getdate(moratorium_end_date): + total_payment = 0 + balance_amount = self.loan_amount + moratorium_interest += interest_amount + elif self.treatment_of_interest == "Add to first repayment" and moratorium_interest: + if moratorium_interest + interest_amount <= total_payment: + interest_amount += moratorium_interest + principal_amount = total_payment - interest_amount + balance_amount = self.loan_amount - principal_amount + moratorium_interest = 0 + + if self.repayment_schedule_type == "Pro-rated calendar months": next_payment_date = get_last_day(payment_date) - if schedule_type_details.repayment_date_on == "Start of the next month": + if self.repayment_date_on == "Start of the next month": next_payment_date = add_days(next_payment_date, 1) payment_date = next_payment_date @@ -63,10 +82,9 @@ def make_repayment_schedule(self): payment_date, principal_amount, interest_amount, total_payment, balance_amount, days ) - if ( - self.repayment_method == "Repay Over Number of Periods" - and len(self.get("repayment_schedule")) >= self.repayment_periods - ): + if self.repayment_method == "Repay Over Number of Periods" and len( + self.get("repayment_schedule") + ) >= self.repayment_periods + cint(self.moratorium_tenure): self.get("repayment_schedule")[-1].principal_amount += balance_amount self.get("repayment_schedule")[-1].balance_loan_amount = 0 self.get("repayment_schedule")[-1].total_payment = ( @@ -76,9 +94,9 @@ def make_repayment_schedule(self): balance_amount = 0 if ( - schedule_type_details.repayment_schedule_type + self.repayment_schedule_type in ["Monthly as per repayment start date", "Monthly as per cycle date"] - or schedule_type_details.repayment_date_on == "End of the current month" + or self.repayment_date_on == "End of the current month" ) and self.repayment_frequency == "Monthly": next_payment_date = add_single_month(payment_date) payment_date = next_payment_date @@ -86,10 +104,11 @@ def make_repayment_schedule(self): payment_date = add_days(payment_date, 7) elif self.repayment_frequency == "Daily": payment_date = add_days(payment_date, 1) - elif self.repayment_type == "Quarterly": + elif self.repayment_frequency == "Quarterly": payment_date = add_months(payment_date, 3) carry_forward_interest = 0 + broken_period_interest_days = 0 def validate_repayment_method(self): if self.repayment_method == "Repay Over Number of Periods" and not self.repayment_periods: @@ -105,64 +124,63 @@ def get_amounts( self, payment_date, balance_amount, - repayment_frequency, - schedule_type, - repayment_date_on, additional_days, carry_forward_interest=0, ): - if repayment_frequency == "Monthly": - if schedule_type == "Monthly as per repayment start date": + days, months = self.get_days_and_months(payment_date, additional_days) + interest_amount = flt(balance_amount * flt(self.rate_of_interest) * days / (months * 100)) + principal_amount = self.monthly_repayment_amount - flt(interest_amount) + balance_amount = flt(balance_amount + interest_amount - self.monthly_repayment_amount) + if balance_amount < 0: + principal_amount += balance_amount + balance_amount = 0.0 + + if carry_forward_interest: + interest_amount += carry_forward_interest + + total_payment = principal_amount + interest_amount + + return interest_amount, principal_amount, balance_amount, total_payment, days + + def get_days_and_months(self, payment_date, additional_days): + if self.repayment_frequency == "Monthly": + if self.repayment_schedule_type == "Monthly as per repayment start date": days = 1 months = 12 else: + months = 365 expected_payment_date = get_last_day(payment_date) - if repayment_date_on == "Start of the next month": + if self.repayment_date_on == "Start of the next month": expected_payment_date = add_days(expected_payment_date, 1) - if schedule_type == "Monthly as per cycle date": + if self.repayment_schedule_type == "Monthly as per cycle date": days = date_diff(payment_date, add_months(payment_date, -1)) if additional_days < 0: - days = date_diff(self.repayment_start_date, self.posting_date) + days = date_diff(payment_date, self.posting_date) additional_days = 0 - months = 365 if additional_days: days += additional_days additional_days = 0 elif expected_payment_date == payment_date: # using 30 days for calculating interest for all full months days = 30 - months = 365 else: days = date_diff(get_last_day(payment_date), payment_date) - months = 365 - elif repayment_frequency == "Weekly": + elif self.repayment_frequency == "Weekly": days = 7 months = 52 - elif repayment_frequency == "Daily": + elif self.repayment_frequency == "Daily": days = 1 months = 365 - elif repayment_frequency == "Quarterly": + elif self.repayment_frequency == "Quarterly": days = 3 months = 12 - elif repayment_frequency == "One Time": + elif self.repayment_frequency == "One Time": days = date_diff(self.repayment_start_date, self.posting_date) months = 365 - interest_amount = flt(balance_amount * flt(self.rate_of_interest) * days / (months * 100)) - principal_amount = self.monthly_repayment_amount - flt(interest_amount) - balance_amount = flt(balance_amount + interest_amount - self.monthly_repayment_amount) - if balance_amount < 0: - principal_amount += balance_amount - balance_amount = 0.0 - - if carry_forward_interest: - interest_amount += carry_forward_interest - - total_payment = principal_amount + interest_amount - - return interest_amount, principal_amount, balance_amount, total_payment, days + return days, months def add_repayment_schedule_row( self, payment_date, principal_amount, interest_amount, total_payment, balance_loan_amount, days diff --git a/lending/loan_management/doctype/loan_write_off/loan_write_off.py b/lending/loan_management/doctype/loan_write_off/loan_write_off.py index 02b69412..f5db0619 100644 --- a/lending/loan_management/doctype/loan_write_off/loan_write_off.py +++ b/lending/loan_management/doctype/loan_write_off/loan_write_off.py @@ -53,11 +53,13 @@ def validate_write_off_amount(self): def on_submit(self): self.update_outstanding_amount() self.make_gl_entries() + self.close_employee_loan() def on_cancel(self): self.update_outstanding_amount(cancel=1) self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"] self.make_gl_entries(cancel=1) + self.close_employee_loan(cancel=1) def update_outstanding_amount(self, cancel=0): written_off_amount = frappe.db.get_value("Loan", self.loan, "written_off_amount") @@ -108,3 +110,39 @@ def make_gl_entries(self, cancel=0): ) make_gl_entries(gl_entries, cancel=cancel, merge_entries=False) + + def close_employee_loan(self, cancel=0): + if not frappe.db.has_column("Loan", "repay_from_salary"): + return + + loan = frappe.get_value( + "Loan", + self.loan, + [ + "total_payment", + "total_principal_paid", + "loan_amount", + "total_interest_payable", + "written_off_amount", + "disbursed_amount", + "status", + "is_secured_loan", + "repay_from_salary", + "name", + ], + as_dict=1, + ) + + if loan.is_secured_loan or not loan.repay_from_salary: + return + + if not cancel: + pending_principal_amount = get_pending_principal_amount(loan) + + precision = cint(frappe.db.get_default("currency_precision")) or 2 + + if flt(pending_principal_amount, precision) <= 0: + frappe.db.set_value("Loan", loan.name, "status", "Closed") + frappe.msgprint(_("Loan {0} closed").format(loan.name)) + else: + frappe.db.set_value("Loan", loan.loan, "status", "Disbursed") diff --git a/lending/loan_management/doctype/process_loan_interest_accrual/process_loan_interest_accrual.py b/lending/loan_management/doctype/process_loan_interest_accrual/process_loan_interest_accrual.py index db1250c7..785a5a1f 100644 --- a/lending/loan_management/doctype/process_loan_interest_accrual/process_loan_interest_accrual.py +++ b/lending/loan_management/doctype/process_loan_interest_accrual/process_loan_interest_accrual.py @@ -75,7 +75,10 @@ def term_loan_accrual_pending(date, loan=None): filters = {"payment_date": ("<=", date), "is_accrued": 0} if loan: - filters.update({"parent": loan}) + loan_repayment_schedule = frappe.db.get_value( + "Loan Repayment Schedule", {"loan": loan, "status": "Active"} + ) + filters.update({"parent": loan_repayment_schedule}) pending_accrual = frappe.db.get_value("Repayment Schedule", filters) diff --git a/lending/patches.txt b/lending/patches.txt index 91d9b04a..78f1849a 100644 --- a/lending/patches.txt +++ b/lending/patches.txt @@ -32,3 +32,4 @@ lending.patches.v15_0.update_min_bpi_application_days lending.patches.v15_0.update_loan_security_assignment_pledge_status lending.patches.v15_0.create_custom_field_for_collection_offset_sequence_for_settlement_collection lending.patches.v15_0.rename_irac_provisioning_configuration_loan_product #1 +lending.patches.v15_0.rename_irac_provisioning_configuration_loan_product