Skip to content

feat: Loan Import Tool#1016

Open
Nihantra-Patel wants to merge 55 commits intofrappe:developfrom
Nihantra-Patel:feat-loan-import-tool-test1
Open

feat: Loan Import Tool#1016
Nihantra-Patel wants to merge 55 commits intofrappe:developfrom
Nihantra-Patel:feat-loan-import-tool-test1

Conversation

@Nihantra-Patel
Copy link
Member

@Nihantra-Patel Nihantra-Patel commented Dec 16, 2025

Frappe Lending Roadmap

For Mid Tenure Loans:

  • Customer creation
  • Loan document with all product details
  • Loan Disbursement (without GL entries for opening)
  • Loan Interest Accrual (consolidated entry)
  • Loan Demand for outstanding amounts
  • Opening GL entry for outstanding principal
  • Mark previous months as demand generated
  • Loan Repayments (collection data)

For Closed Loans:

  • Loan document (account data)
  • Loan Repayments (collection data)

Import Mid Tenure Loans

Mid.Tenure.Loans.mov

Import Closed Loans

Closed.Loans.mov

Create Missing Customers

Create.Missing.Customers.mov

Partial Success

Partial.Success.mov

Import Loan Repayment

Loan.Repayment.Import.mov

@codecov-commenter
Copy link

codecov-commenter commented Dec 16, 2025

Codecov Report

❌ Patch coverage is 80.00000% with 35 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.24%. Comparing base (976625b) to head (2900d9b).

Files with missing lines Patch % Lines
...anagement/doctype/loan_repayment/loan_repayment.py 20.00% 20 Missing ⚠️
lending/loan_management/doctype/loan/loan.py 86.13% 14 Missing ⚠️
...loan_repayment_schedule/loan_repayment_schedule.py 50.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #1016      +/-   ##
===========================================
+ Coverage    75.22%   75.24%   +0.02%     
===========================================
  Files          132      133       +1     
  Lines         9239     9409     +170     
===========================================
+ Hits          6950     7080     +130     
- Misses        2289     2329      +40     
Files with missing lines Coverage Δ
...ing/loan_management/controllers/loan_controller.py 91.66% <100.00%> (+4.16%) ⬆️
lending/loan_management/doctype/loan/test_loan.py 99.48% <100.00%> (+<0.01%) ⬆️
...loan_management/doctype/loan_demand/loan_demand.py 85.02% <100.00%> (+0.06%) ⬆️
...ent/doctype/loan_disbursement/loan_disbursement.py 77.96% <100.00%> (+0.53%) ⬆️
...doctype/loan_import_details/loan_import_details.py 100.00% <100.00%> (ø)
...ype/loan_interest_accrual/loan_interest_accrual.py 87.04% <100.00%> (+0.02%) ⬆️
...ype/loan_repayment_repost/loan_repayment_repost.py 78.20% <100.00%> (ø)
lending/loan_management/utils.py 21.48% <100.00%> (+8.64%) ⬆️
lending/tests/test_utils.py 95.82% <100.00%> (+0.02%) ⬆️
...loan_repayment_schedule/loan_repayment_schedule.py 85.81% <50.00%> (-0.17%) ⬇️
... and 2 more

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Member

@deepeshgarg007 deepeshgarg007 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, the standard GL's need to be skipped for the imported loans. Didn't find code for that anywhere

},
{
"default": "0",
"fieldname": "is_imported",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should have a fetch from the loan doctype, right?

},
{
"default": "0",
"fieldname": "is_imported",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, should be imported from the loan document

return created_disbursements


def create_gl_entries(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't directly create GL Entries. Use the ERPNext make_gl_entry api the way every doctype does

@deepeshgarg007 deepeshgarg007 marked this pull request as ready for review January 9, 2026 09:03

def get_mandatory_fieldnames(self):
if self.import_for == "Loan Repayment":
return ["loan_repayment_id", "against_loan", "loan_disbursement", "repayment_type", "posting_date", "value_date", "amount_paid",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get meta fields from meta instead of hardcoding

def get_numeric_fieldnames(self):
if self.import_for == "Loan Repayment":
return {
"amount_paid", "principal_amount_paid", "total_interest_paid", "total_penalty_paid", "total_charges_paid",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try getting this fields from meta as well

]
return []

def prevalidate_and_make_logs(self, di_name: str, payloads, parsed_file) -> bool:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to do mandatory validation check. That is already enforced by document config and mandatory fields

Comment on lines 241 to 251
def validate_import_mandatory_fields(self):
if not self.is_imported:
return

migration_date = self.get("migration_date")
if not migration_date:
return

if self.get("status") == "Closed":
frappe.db.set_value("Loan", self.name, "status", "Closed")
return
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 3 can be clubbed in one function or condition

Comment on lines 282 to 286
"opening_principal_outstanding",
"opening_interest_outstanding",
"opening_penalty_outstanding",
"opening_additional_outstanding",
"opening_charge_outstanding",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mandatory depends on config can be done for these fields based on is_imported check, right?

Comment on lines 308 to 315
if not self.is_imported:
return

if not self.get("migration_date"):
return

if self.get("status")== "Closed":
return
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repetition from above, commonify in a function and use in both places

Comment on lines 2283 to 2296
def update_demand_generated_for_repayment_schedule(loan_name, disbursement_name, migration_date):
frappe.db.sql(
"""
UPDATE `tabRepayment Schedule` rs
INNER JOIN `tabLoan Repayment Schedule` lrs ON lrs.name = rs.parent
SET rs.demand_generated = 1
WHERE lrs.loan = %s
AND lrs.loan_disbursement = %s
AND lrs.docstatus = 1
AND lrs.status = 'Active'
AND rs.payment_date < %s
""",
(loan_name, disbursement_name, migration_date),
) No newline at end of file
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A similar query is there in loan_repayment_repost_tool.py. I guess that can be used here as well without using any joins. Make a utils field and move it there and try and reuse in both.

Comment on lines 470 to 497
common = {
"doctype": "Loan Interest Accrual",
"company": self.company,
"loan": self.name,
"loan_disbursement": disbursement_name,
"posting_date": migration_date,
"accrual_date": migration_date,
"accrual_type": "Regular",
"applicant_type": self.applicant_type,
"applicant": self.applicant,
"loan_product": self.loan_product,
"rate_of_interest": flt(self.get("rate_of_interest") or 0, precision),
"is_imported": 1,
}

if interest_outstanding > 0:
doc = frappe.get_doc(
{
**common,
"interest_type": "Normal Interest",
"base_amount": principal_outstanding,
"interest_amount": interest_outstanding,
"additional_interest_amount": 0,
"penalty_amount": 0,
}
)
doc.insert(ignore_permissions=True)
doc.submit()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not reuse make_loan_interest_accrual_entry from loan_interest_accrual.py?

frappe.db.set_value("Loan Demand", demand.name, "is_imported", 1)


def create_migrated_loan_disbursement(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def create_migrated_loan_disbursement(
def import_loan_disbursement(

Comment on lines 247 to 258
def get_migration_date_for_import(self):
if not self.get("is_imported"):
return None

migration_date = self.get("migration_date")
if not migration_date:
return None

if self.get("status") == "Closed":
return None

return migration_date
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just make the migration date mandatory for all migrated loans irrespective of closed or active loans?

Comment on lines 260 to 267
def is_line_of_credit_loan(self):
if self.get("repayment_schedule_type"):
return self.repayment_schedule_type == "Line of Credit"

return (
frappe.db.get_value("Loan Product", self.loan_product, "repayment_schedule_type")
== "Line of Credit"
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

repayment schedule type is anyways fetch from loan product, why check separately in the loan product?

Comment on lines 270 to 275
migration_date = self.get_migration_date_for_import()
if not migration_date:
return

precision = cint(frappe.db.get_default("currency_precision")) or 2
migration_date = self.migration_date
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Migration date is being assigned twice
Also, why not just use self.migration_date instead of assigning in a variable?

migration_date = self.migration_date
is_loc = self.is_line_of_credit_loan()

for d in (self.get("loan_import_details") or []):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
for d in (self.get("loan_import_details") or []):
for d in (self.get("loan_import_details")):

Comment on lines 279 to 281
loan_disbursement_id = d.loan_disbursement_id
disbursed_amount = flt(d.disbursed_amount, precision)
disbursement_date = d.disbursement_date
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why assign to a variable when d.disbursement_date or d.loan_disbursement_id can be used. This adds 3 unnecessary extra lines

if existing:
return existing

disbursement_date = disbursement_date
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

???

Comment on lines 390 to 400
if self.get("repayment_schedule_type"):
data["repayment_schedule_type"] = self.repayment_schedule_type

if repayment_method:
data["repayment_method"] = repayment_method
if repayment_frequency:
data["repayment_frequency"] = repayment_frequency
if tenure:
data["tenure"] = tenure
if repayment_start_date:
data["repayment_start_date"] = repayment_start_date
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These values can be directly assigned in the data dict. No need to check for if repayment_method. get_doc is capalbe to handle None values

Comment on lines 424 to 429
principal_outstanding = flt(principal_outstanding or 0, precision)
interest_outstanding = flt(interest_outstanding or 0, precision)
penalty_outstanding = flt(penalty_outstanding or 0, precision)
additional_outstanding = flt(additional_outstanding or 0, precision)

rate_of_interest = flt(self.get("rate_of_interest") or 0, precision)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is or 0 needed? flt can handle None values

Image

Comment on lines 750 to 751
if not self.loan_product:
frappe.throw(_("Loan Product is mandatory to create opening GL entries."))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't the loan product anyway a mandatory column?

Comment on lines 760 to 761
loan_account = acc.get("loan_account")
payment_account = acc.get("payment_account")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary variable assignment

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

Comments