diff --git a/erpnext/accounts/doctype/bank/bank.js b/erpnext/accounts/doctype/bank/bank.js index 059e1d31588..35d606ba3ae 100644 --- a/erpnext/accounts/doctype/bank/bank.js +++ b/erpnext/accounts/doctype/bank/bank.js @@ -118,6 +118,10 @@ erpnext.integrations.refreshPlaidLink = class refreshPlaidLink { } plaid_success(token, response) { - frappe.show_alert({ message: __('Plaid Link Updated'), indicator: 'green' }); + frappe.xcall('erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.update_bank_account_ids', { + response: response, + }).then(() => { + frappe.show_alert({ message: __('Plaid Link Updated'), indicator: 'green' }); + }); } }; diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js index c083189eb27..ae84154f2df 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js @@ -155,7 +155,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", { } }, - render_chart: frappe.utils.debounce((frm) => { + render_chart(frm) { frm.cards_manager = new erpnext.accounts.bank_reconciliation.NumberCardManager( { $reconciliation_tool_cards: frm.get_field( @@ -167,7 +167,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", { currency: frm.currency, } ); - }, 500), + }, render(frm) { if (frm.doc.bank_account) { diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py index 57bc351f414..c4a23a640c3 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py @@ -10,7 +10,7 @@ from frappe.model.document import Document from frappe.query_builder.custom import ConstantColumn from frappe.utils import cint, flt -from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_paid_amount +from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_total_allocated_amount from erpnext.accounts.report.bank_reconciliation_statement.bank_reconciliation_statement import ( get_amounts_not_reflected_in_system, get_entries, @@ -28,7 +28,7 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None): filters = [] filters.append(["bank_account", "=", bank_account]) filters.append(["docstatus", "=", 1]) - filters.append(["unallocated_amount", ">", 0]) + filters.append(["unallocated_amount", ">", 0.0]) if to_date: filters.append(["date", "<=", to_date]) if from_date: @@ -66,7 +66,7 @@ def get_account_balance(bank_account, till_date): balance_as_per_system = get_balance_on(filters["account"], filters["report_date"]) - total_debit, total_credit = 0, 0 + total_debit, total_credit = 0.0, 0.0 for d in data: total_debit += flt(d.debit) total_credit += flt(d.credit) @@ -145,10 +145,8 @@ def create_journal_entry_bts( accounts.append( { "account": second_account, - "credit_in_account_currency": bank_transaction.deposit if bank_transaction.deposit > 0 else 0, - "debit_in_account_currency": bank_transaction.withdrawal - if bank_transaction.withdrawal > 0 - else 0, + "credit_in_account_currency": bank_transaction.deposit, + "debit_in_account_currency": bank_transaction.withdrawal, "party_type": party_type, "party": party, } @@ -158,10 +156,8 @@ def create_journal_entry_bts( { "account": company_account, "bank_account": bank_transaction.bank_account, - "credit_in_account_currency": bank_transaction.withdrawal - if bank_transaction.withdrawal > 0 - else 0, - "debit_in_account_currency": bank_transaction.deposit if bank_transaction.deposit > 0 else 0, + "credit_in_account_currency": bank_transaction.withdrawal, + "debit_in_account_currency": bank_transaction.deposit, } ) @@ -185,16 +181,22 @@ def create_journal_entry_bts( journal_entry.insert() journal_entry.submit() - if bank_transaction.deposit > 0: + if bank_transaction.deposit > 0.0: paid_amount = bank_transaction.deposit else: paid_amount = bank_transaction.withdrawal vouchers = json.dumps( - [{"payment_doctype": "Journal Entry", "payment_name": journal_entry.name, "amount": paid_amount}] + [ + { + "payment_doctype": "Journal Entry", + "payment_name": journal_entry.name, + "amount": paid_amount, + } + ] ) - return reconcile_vouchers(bank_transaction.name, vouchers) + return reconcile_vouchers(bank_transaction_name, vouchers) @frappe.whitelist() @@ -218,7 +220,7 @@ def create_payment_entry_bts( as_dict=True, )[0] paid_amount = bank_transaction.unallocated_amount - payment_type = "Receive" if bank_transaction.deposit > 0 else "Pay" + payment_type = "Receive" if bank_transaction.deposit > 0.0 else "Pay" company_account = frappe.get_value("Bank Account", bank_transaction.bank_account, "account") company = frappe.get_value("Account", company_account, "company") @@ -257,9 +259,15 @@ def create_payment_entry_bts( payment_entry.submit() vouchers = json.dumps( - [{"payment_doctype": "Payment Entry", "payment_name": payment_entry.name, "amount": paid_amount}] + [ + { + "payment_doctype": "Payment Entry", + "payment_name": payment_entry.name, + "amount": paid_amount, + } + ] ) - return reconcile_vouchers(bank_transaction.name, vouchers) + return reconcile_vouchers(bank_transaction_name, vouchers) @frappe.whitelist() @@ -341,59 +349,7 @@ def reconcile_vouchers(bank_transaction_name, vouchers): # updated clear date of all the vouchers based on the bank transaction vouchers = json.loads(vouchers) transaction = frappe.get_doc("Bank Transaction", bank_transaction_name) - company_account = frappe.db.get_value("Bank Account", transaction.bank_account, "account") - - if transaction.unallocated_amount == 0: - frappe.throw(_("This bank transaction is already fully reconciled")) - total_amount = 0 - for voucher in vouchers: - voucher["payment_entry"] = frappe.get_doc(voucher["payment_doctype"], voucher["payment_name"]) - total_amount += get_paid_amount( - frappe._dict( - { - "payment_document": voucher["payment_doctype"], - "payment_entry": voucher["payment_name"], - } - ), - transaction.currency, - company_account, - ) - - if total_amount > transaction.unallocated_amount: - frappe.throw( - _( - "The sum total of amounts of all selected vouchers should be less than the unallocated amount of the bank transaction" - ) - ) - account = frappe.db.get_value("Bank Account", transaction.bank_account, "account") - - for voucher in vouchers: - gl_entry = frappe.db.get_value( - "GL Entry", - dict( - account=account, voucher_type=voucher["payment_doctype"], voucher_no=voucher["payment_name"] - ), - ["credit_in_account_currency as credit", "debit_in_account_currency as debit"], - as_dict=1, - ) - gl_amount, transaction_amount = ( - (gl_entry.credit, transaction.deposit) - if gl_entry.credit > 0 - else (gl_entry.debit, transaction.withdrawal) - ) - allocated_amount = gl_amount if gl_amount >= transaction_amount else transaction_amount - - transaction.append( - "payment_entries", - { - "payment_document": voucher["payment_entry"].doctype, - "payment_entry": voucher["payment_entry"].name, - "allocated_amount": allocated_amount, - }, - ) - - transaction.save() - transaction.update_allocations() + transaction.add_payment_entries(vouchers) return frappe.get_doc("Bank Transaction", bank_transaction_name) @@ -412,9 +368,9 @@ def get_linked_payments( bank_account = frappe.db.get_values( "Bank Account", transaction.bank_account, ["account", "company"], as_dict=True )[0] - (account, company) = (bank_account.account, bank_account.company) + (gl_account, company) = (bank_account.account, bank_account.company) matching = check_matching( - account, + gl_account, company, transaction, document_types, @@ -424,7 +380,27 @@ def get_linked_payments( from_reference_date, to_reference_date, ) - return matching + return subtract_allocations(gl_account, matching) + + +def subtract_allocations(gl_account, vouchers): + "Look up & subtract any existing Bank Transaction allocations" + copied = [] + for voucher in vouchers: + rows = get_total_allocated_amount(voucher[1], voucher[2]) + amount = None + for row in rows: + if row["gl_account"] == gl_account: + amount = row["total"] + break + + if amount: + l = list(voucher) + l[3] -= amount + copied.append(tuple(l)) + else: + copied.append(voucher) + return copied def check_matching( @@ -438,6 +414,7 @@ def check_matching( from_reference_date, to_reference_date, ): + exact_match = True if "exact_match" in document_types else False # combine all types of vouchers subquery = get_queries( bank_account, @@ -449,10 +426,11 @@ def check_matching( filter_by_reference_date, from_reference_date, to_reference_date, + exact_match, ) filters = { "amount": transaction.unallocated_amount, - "payment_type": "Receive" if transaction.deposit > 0 else "Pay", + "payment_type": "Receive" if transaction.deposit > 0.0 else "Pay", "reference_no": transaction.reference_number, "party_type": transaction.party_type, "party": transaction.party, @@ -461,7 +439,9 @@ def check_matching( matching_vouchers = [] - matching_vouchers.extend(get_loan_vouchers(bank_account, transaction, document_types, filters)) + matching_vouchers.extend( + get_loan_vouchers(bank_account, transaction, document_types, filters, exact_match) + ) for query in subquery: matching_vouchers.extend( @@ -483,10 +463,10 @@ def get_queries( filter_by_reference_date, from_reference_date, to_reference_date, + exact_match, ): # get queries to get matching vouchers - amount_condition = "=" if "exact_match" in document_types else "<=" - account_from_to = "paid_to" if transaction.deposit > 0 else "paid_from" + account_from_to = "paid_to" if transaction.deposit > 0.0 else "paid_from" queries = [] # get matching queries from all the apps @@ -497,7 +477,7 @@ def get_queries( company, transaction, document_types, - amount_condition, + exact_match, account_from_to, from_date, to_date, @@ -516,7 +496,7 @@ def get_matching_queries( company, transaction, document_types, - amount_condition, + exact_match, account_from_to, from_date, to_date, @@ -526,8 +506,8 @@ def get_matching_queries( ): queries = [] if "payment_entry" in document_types: - pe_amount_matching = get_pe_matching_query( - amount_condition, + query = get_pe_matching_query( + exact_match, account_from_to, transaction, from_date, @@ -536,11 +516,11 @@ def get_matching_queries( from_reference_date, to_reference_date, ) - queries.extend([pe_amount_matching]) + queries.append(query) if "journal_entry" in document_types: - je_amount_matching = get_je_matching_query( - amount_condition, + query = get_je_matching_query( + exact_match, transaction, from_date, to_date, @@ -548,34 +528,70 @@ def get_matching_queries( from_reference_date, to_reference_date, ) - queries.extend([je_amount_matching]) + queries.append(query) - if transaction.deposit > 0 and "sales_invoice" in document_types: - si_amount_matching = get_si_matching_query(amount_condition) - queries.extend([si_amount_matching]) + if transaction.deposit > 0.0 and "sales_invoice" in document_types: + query = get_si_matching_query(exact_match) + queries.append(query) - if transaction.withdrawal > 0: + if transaction.withdrawal > 0.0: if "purchase_invoice" in document_types: - pi_amount_matching = get_pi_matching_query(amount_condition) - queries.extend([pi_amount_matching]) + query = get_pi_matching_query(exact_match) + queries.append(query) + + if "bank_transaction" in document_types: + query = get_bt_matching_query(exact_match, transaction) + queries.append(query) return queries -def get_loan_vouchers(bank_account, transaction, document_types, filters): +def get_loan_vouchers(bank_account, transaction, document_types, filters, exact_match): vouchers = [] - amount_condition = True if "exact_match" in document_types else False - if transaction.withdrawal > 0 and "loan_disbursement" in document_types: - vouchers.extend(get_ld_matching_query(bank_account, amount_condition, filters)) + if transaction.withdrawal > 0.0 and "loan_disbursement" in document_types: + vouchers.extend(get_ld_matching_query(bank_account, exact_match, filters)) - if transaction.deposit > 0 and "loan_repayment" in document_types: - vouchers.extend(get_lr_matching_query(bank_account, amount_condition, filters)) + if transaction.deposit > 0.0 and "loan_repayment" in document_types: + vouchers.extend(get_lr_matching_query(bank_account, exact_match, filters)) return vouchers -def get_ld_matching_query(bank_account, amount_condition, filters): +def get_bt_matching_query(exact_match, transaction): + # get matching bank transaction query + # find bank transactions in the same bank account with opposite sign + # same bank account must have same company and currency + field = "deposit" if transaction.withdrawal > 0.0 else "withdrawal" + + return f""" + + SELECT + (CASE WHEN reference_number = %(reference_no)s THEN 1 ELSE 0 END + + CASE WHEN {field} = %(amount)s THEN 1 ELSE 0 END + + CASE WHEN ( party_type = %(party_type)s AND party = %(party)s ) THEN 1 ELSE 0 END + + CASE WHEN unallocated_amount = %(amount)s THEN 1 ELSE 0 END + + 1) AS rank, + 'Bank Transaction' AS doctype, + name, + unallocated_amount AS paid_amount, + reference_number AS reference_no, + date AS reference_date, + party, + party_type, + date AS posting_date, + currency + FROM + `tabBank Transaction` + WHERE + status != 'Reconciled' + AND name != '{transaction.name}' + AND bank_account = '{transaction.bank_account}' + AND {field} {'= %(amount)s' if exact_match else '> 0.0'} + """ + + +def get_ld_matching_query(bank_account, exact_match, filters): loan_disbursement = frappe.qb.DocType("Loan Disbursement") matching_reference = loan_disbursement.reference_number == filters.get("reference_number") matching_party = loan_disbursement.applicant_type == filters.get( @@ -603,17 +619,17 @@ def get_ld_matching_query(bank_account, amount_condition, filters): .where(loan_disbursement.disbursement_account == bank_account) ) - if amount_condition: + if exact_match: query.where(loan_disbursement.disbursed_amount == filters.get("amount")) else: - query.where(loan_disbursement.disbursed_amount <= filters.get("amount")) + query.where(loan_disbursement.disbursed_amount > 0.0) vouchers = query.run(as_list=True) return vouchers -def get_lr_matching_query(bank_account, amount_condition, filters): +def get_lr_matching_query(bank_account, exact_match, filters): loan_repayment = frappe.qb.DocType("Loan Repayment") matching_reference = loan_repayment.reference_number == filters.get("reference_number") matching_party = loan_repayment.applicant_type == filters.get( @@ -644,10 +660,10 @@ def get_lr_matching_query(bank_account, amount_condition, filters): if frappe.db.has_column("Loan Repayment", "repay_from_salary"): query = query.where((loan_repayment.repay_from_salary == 0)) - if amount_condition: + if exact_match: query.where(loan_repayment.amount_paid == filters.get("amount")) else: - query.where(loan_repayment.amount_paid <= filters.get("amount")) + query.where(loan_repayment.amount_paid > 0.0) vouchers = query.run() @@ -655,7 +671,7 @@ def get_lr_matching_query(bank_account, amount_condition, filters): def get_pe_matching_query( - amount_condition, + exact_match, account_from_to, transaction, from_date, @@ -665,7 +681,7 @@ def get_pe_matching_query( to_reference_date, ): # get matching payment entries query - if transaction.deposit > 0: + if transaction.deposit > 0.0: currency_field = "paid_to_account_currency as currency" else: currency_field = "paid_from_account_currency as currency" @@ -680,7 +696,8 @@ def get_pe_matching_query( return f""" SELECT (CASE WHEN reference_no=%(reference_no)s THEN 1 ELSE 0 END - + CASE WHEN (party_type = %(party_type)s AND party = %(party)s ) THEN 1 ELSE 0 END + + CASE WHEN (party_type = %(party_type)s AND party = %(party)s ) THEN 1 ELSE 0 END + + CASE WHEN paid_amount = %(amount)s THEN 1 ELSE 0 END + 1 ) AS rank, 'Payment Entry' as doctype, name, @@ -694,20 +711,19 @@ def get_pe_matching_query( FROM `tabPayment Entry` WHERE - paid_amount {amount_condition} %(amount)s - AND docstatus = 1 + docstatus = 1 AND payment_type IN (%(payment_type)s, 'Internal Transfer') AND ifnull(clearance_date, '') = "" AND {account_from_to} = %(bank_account)s + AND paid_amount {'= %(amount)s' if exact_match else '> 0.0'} {filter_by_date} {filter_by_reference_no} order by{order_by} - """ def get_je_matching_query( - amount_condition, + exact_match, transaction, from_date, to_date, @@ -719,7 +735,7 @@ def get_je_matching_query( # We have mapping at the bank level # So one bank could have both types of bank accounts like asset and liability # So cr_or_dr should be judged only on basis of withdrawal and deposit and not account type - cr_or_dr = "credit" if transaction.withdrawal > 0 else "debit" + cr_or_dr = "credit" if transaction.withdrawal > 0.0 else "debit" filter_by_date = f"AND je.posting_date between '{from_date}' and '{to_date}'" order_by = " je.posting_date" filter_by_reference_no = "" @@ -731,26 +747,29 @@ def get_je_matching_query( return f""" SELECT (CASE WHEN je.cheque_no=%(reference_no)s THEN 1 ELSE 0 END + + CASE WHEN jea.{cr_or_dr}_in_account_currency = %(amount)s THEN 1 ELSE 0 END + 1) AS rank , - 'Journal Entry' as doctype, + 'Journal Entry' AS doctype, je.name, - jea.{cr_or_dr}_in_account_currency as paid_amount, - je.cheque_no as reference_no, - je.cheque_date as reference_date, - je.pay_to_recd_from as party, + jea.{cr_or_dr}_in_account_currency AS paid_amount, + je.cheque_no AS reference_no, + je.cheque_date AS reference_date, + je.pay_to_recd_from AS party, jea.party_type, je.posting_date, - jea.account_currency as currency + jea.account_currency AS currency FROM - `tabJournal Entry Account` as jea + `tabJournal Entry Account` AS jea JOIN - `tabJournal Entry` as je + `tabJournal Entry` AS je ON jea.parent = je.name WHERE - (je.clearance_date is null or je.clearance_date='0000-00-00') + je.docstatus = 1 + AND je.voucher_type NOT IN ('Opening Entry') + AND (je.clearance_date IS NULL OR je.clearance_date='0000-00-00') AND jea.account = %(bank_account)s - AND jea.{cr_or_dr}_in_account_currency {amount_condition} %(amount)s + AND jea.{cr_or_dr}_in_account_currency {'= %(amount)s' if exact_match else '> 0.0'} AND je.docstatus = 1 {filter_by_date} {filter_by_reference_no} @@ -758,11 +777,12 @@ def get_je_matching_query( """ -def get_si_matching_query(amount_condition): - # get matchin sales invoice query +def get_si_matching_query(exact_match): + # get matching sales invoice query return f""" SELECT - ( CASE WHEN si.customer = %(party)s THEN 1 ELSE 0 END + ( CASE WHEN si.customer = %(party)s THEN 1 ELSE 0 END + + CASE WHEN sip.amount = %(amount)s THEN 1 ELSE 0 END + 1 ) AS rank, 'Sales Invoice' as doctype, si.name, @@ -780,18 +800,20 @@ def get_si_matching_query(amount_condition): `tabSales Invoice` as si ON sip.parent = si.name - WHERE (sip.clearance_date is null or sip.clearance_date='0000-00-00') + WHERE + si.docstatus = 1 + AND (sip.clearance_date is null or sip.clearance_date='0000-00-00') AND sip.account = %(bank_account)s - AND sip.amount {amount_condition} %(amount)s - AND si.docstatus = 1 + AND sip.amount {'= %(amount)s' if exact_match else '> 0.0'} """ -def get_pi_matching_query(amount_condition): - # get matching purchase invoice query +def get_pi_matching_query(exact_match): + # get matching purchase invoice query when they are also used as payment entries (is_paid) return f""" SELECT ( CASE WHEN supplier = %(party)s THEN 1 ELSE 0 END + + CASE WHEN paid_amount = %(amount)s THEN 1 ELSE 0 END + 1 ) AS rank, 'Purchase Invoice' as doctype, name, @@ -805,9 +827,9 @@ def get_pi_matching_query(amount_condition): FROM `tabPurchase Invoice` WHERE - paid_amount {amount_condition} %(amount)s - AND docstatus = 1 + docstatus = 1 AND is_paid = 1 AND ifnull(clearance_date, '') = "" - AND cash_bank_account = %(bank_account)s + AND cash_bank_account = %(bank_account)s + AND paid_amount {'= %(amount)s' if exact_match else '> 0.0'} """ diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.js b/erpnext/accounts/doctype/bank_transaction/bank_transaction.js index 6f2900a6808..e548b4c7e9a 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.js +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.js @@ -12,8 +12,13 @@ frappe.ui.form.on("Bank Transaction", { }; }); }, - - bank_account: function(frm) { + refresh(frm) { + frm.add_custom_button(__('Unreconcile Transaction'), () => { + frm.call('remove_payment_entries') + .then( () => frm.refresh() ); + }); + }, + bank_account: function (frm) { set_bank_statement_filter(frm); }, @@ -34,6 +39,7 @@ frappe.ui.form.on("Bank Transaction", { "Journal Entry", "Sales Invoice", "Purchase Invoice", + "Bank Transaction", ]; } }); @@ -49,7 +55,7 @@ const update_clearance_date = (frm, cdt, cdn) => { frappe .xcall( "erpnext.accounts.doctype.bank_transaction.bank_transaction.unclear_reference_payment", - { doctype: cdt, docname: cdn } + { doctype: cdt, docname: cdn, bt_name: frm.doc.name } ) .then((e) => { if (e == "success") { diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.json b/erpnext/accounts/doctype/bank_transaction/bank_transaction.json index 2bdaa1049b7..768d2f0fa45 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.json +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.json @@ -20,9 +20,11 @@ "currency", "section_break_10", "description", - "section_break_14", "reference_number", + "column_break_10", "transaction_id", + "transaction_type", + "section_break_14", "payment_entries", "section_break_18", "allocated_amount", @@ -190,11 +192,21 @@ "label": "Withdrawal", "oldfieldname": "credit", "options": "currency" + }, + { + "fieldname": "column_break_10", + "fieldtype": "Column Break" + }, + { + "fieldname": "transaction_type", + "fieldtype": "Data", + "label": "Transaction Type", + "length": 50 } ], "is_submittable": 1, "links": [], - "modified": "2022-03-21 19:05:04.208222", + "modified": "2022-05-29 18:36:50.475964", "modified_by": "Administrator", "module": "Accounts", "name": "Bank Transaction", @@ -248,4 +260,4 @@ "states": [], "title_field": "bank_account", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py index 9b36c93a0f3..15162376c15 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py @@ -1,9 +1,6 @@ # Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt - -from functools import reduce - import frappe from frappe.utils import flt @@ -18,72 +15,137 @@ class BankTransaction(StatusUpdater): self.clear_linked_payment_entries() self.set_status() + _saving_flag = False + + # nosemgrep: frappe-semgrep-rules.rules.frappe-modifying-but-not-comitting def on_update_after_submit(self): - self.update_allocations() - self.clear_linked_payment_entries() - self.set_status(update=True) + "Run on save(). Avoid recursion caused by multiple saves" + if not self._saving_flag: + self._saving_flag = True + self.clear_linked_payment_entries() + self.update_allocations() + self._saving_flag = False def on_cancel(self): self.clear_linked_payment_entries(for_cancel=True) self.set_status(update=True) def update_allocations(self): + "The doctype does not allow modifications after submission, so write to the db direct" if self.payment_entries: - allocated_amount = reduce( - lambda x, y: flt(x) + flt(y), [x.allocated_amount for x in self.payment_entries] - ) + allocated_amount = sum(p.allocated_amount for p in self.payment_entries) else: - allocated_amount = 0 + allocated_amount = 0.0 - if allocated_amount: - frappe.db.set_value(self.doctype, self.name, "allocated_amount", flt(allocated_amount)) - frappe.db.set_value( - self.doctype, - self.name, - "unallocated_amount", - abs(flt(self.withdrawal) - flt(self.deposit)) - flt(allocated_amount), - ) + amount = abs(flt(self.withdrawal) - flt(self.deposit)) + self.db_set("allocated_amount", flt(allocated_amount)) + self.db_set("unallocated_amount", amount - flt(allocated_amount)) + self.reload() + self.set_status(update=True) - else: - frappe.db.set_value(self.doctype, self.name, "allocated_amount", 0) - frappe.db.set_value( - self.doctype, self.name, "unallocated_amount", abs(flt(self.withdrawal) - flt(self.deposit)) - ) + def add_payment_entries(self, vouchers): + "Add the vouchers with zero allocation. Save() will perform the allocations and clearance" + if 0.0 >= self.unallocated_amount: + frappe.throw(frappe._(f"Bank Transaction {self.name} is already fully reconciled")) - amount = self.deposit or self.withdrawal - if amount == self.allocated_amount: - frappe.db.set_value(self.doctype, self.name, "status", "Reconciled") + added = False + for voucher in vouchers: + # Can't add same voucher twice + found = False + for pe in self.payment_entries: + if ( + pe.payment_document == voucher["payment_doctype"] + and pe.payment_entry == voucher["payment_name"] + ): + found = True + + if not found: + pe = { + "payment_document": voucher["payment_doctype"], + "payment_entry": voucher["payment_name"], + "allocated_amount": 0.0, # Temporary + } + child = self.append("payment_entries", pe) + added = True + + # runs on_update_after_submit + if added: + self.save() + + def allocate_payment_entries(self): + """Refactored from bank reconciliation tool. + Non-zero allocations must be amended/cleared manually + Get the bank transaction amount (b) and remove as we allocate + For each payment_entry if allocated_amount == 0: + - get the amount already allocated against all transactions (t), need latest date + - get the voucher amount (from gl) (v) + - allocate (a = v - t) + - a = 0: should already be cleared, so clear & remove payment_entry + - 0 < a <= u: allocate a & clear + - 0 < a, a > u: allocate u + - 0 > a: Error: already over-allocated + - clear means: set the latest transaction date as clearance date + """ + gl_bank_account = frappe.db.get_value("Bank Account", self.bank_account, "account") + remaining_amount = self.unallocated_amount + for payment_entry in self.payment_entries: + if payment_entry.allocated_amount == 0.0: + unallocated_amount, should_clear, latest_transaction = get_clearance_details( + self, payment_entry + ) + + if 0.0 == unallocated_amount: + if should_clear: + latest_transaction.clear_linked_payment_entry(payment_entry) + self.db_delete_payment_entry(payment_entry) + + elif remaining_amount <= 0.0: + self.db_delete_payment_entry(payment_entry) + + elif 0.0 < unallocated_amount and unallocated_amount <= remaining_amount: + payment_entry.db_set("allocated_amount", unallocated_amount) + remaining_amount -= unallocated_amount + if should_clear: + latest_transaction.clear_linked_payment_entry(payment_entry) + + elif 0.0 < unallocated_amount and unallocated_amount > remaining_amount: + payment_entry.db_set("allocated_amount", remaining_amount) + remaining_amount = 0.0 + + elif 0.0 > unallocated_amount: + self.db_delete_payment_entry(payment_entry) + frappe.throw( + frappe._(f"Voucher {payment_entry.payment_entry} is over-allocated by {unallocated_amount}") + ) self.reload() - def clear_linked_payment_entries(self, for_cancel=False): + def db_delete_payment_entry(self, payment_entry): + frappe.db.delete("Bank Transaction Payments", {"name": payment_entry.name}) + + @frappe.whitelist() + def remove_payment_entries(self): for payment_entry in self.payment_entries: - if payment_entry.payment_document == "Sales Invoice": - self.clear_sales_invoice(payment_entry, for_cancel=for_cancel) - elif payment_entry.payment_document in get_doctypes_for_bank_reconciliation(): - self.clear_simple_entry(payment_entry, for_cancel=for_cancel) + self.remove_payment_entry(payment_entry) + # runs on_update_after_submit + self.save() - def clear_simple_entry(self, payment_entry, for_cancel=False): - if payment_entry.payment_document == "Payment Entry": - if ( - frappe.db.get_value("Payment Entry", payment_entry.payment_entry, "payment_type") - == "Internal Transfer" - ): - if len(get_reconciled_bank_transactions(payment_entry)) < 2: - return + def remove_payment_entry(self, payment_entry): + "Clear payment entry and clearance" + self.clear_linked_payment_entry(payment_entry, for_cancel=True) + self.remove(payment_entry) - clearance_date = self.date if not for_cancel else None - frappe.db.set_value( - payment_entry.payment_document, payment_entry.payment_entry, "clearance_date", clearance_date - ) + def clear_linked_payment_entries(self, for_cancel=False): + if for_cancel: + for payment_entry in self.payment_entries: + self.clear_linked_payment_entry(payment_entry, for_cancel) + else: + self.allocate_payment_entries() - def clear_sales_invoice(self, payment_entry, for_cancel=False): - clearance_date = self.date if not for_cancel else None - frappe.db.set_value( - "Sales Invoice Payment", - dict(parenttype=payment_entry.payment_document, parent=payment_entry.payment_entry), - "clearance_date", - clearance_date, + def clear_linked_payment_entry(self, payment_entry, for_cancel=False): + clearance_date = None if for_cancel else self.date + set_voucher_clearance( + payment_entry.payment_document, payment_entry.payment_entry, clearance_date, self ) @@ -93,38 +155,112 @@ def get_doctypes_for_bank_reconciliation(): return frappe.get_hooks("bank_reconciliation_doctypes") -def get_reconciled_bank_transactions(payment_entry): - reconciled_bank_transactions = frappe.get_all( - "Bank Transaction Payments", - filters={"payment_entry": payment_entry.payment_entry}, - fields=["parent"], +def get_clearance_details(transaction, payment_entry): + """ + There should only be one bank gle for a voucher. + Could be none for a Bank Transaction. + But if a JE, could affect two banks. + Should only clear the voucher if all bank gles are allocated. + """ + gl_bank_account = frappe.db.get_value("Bank Account", transaction.bank_account, "account") + gles = get_related_bank_gl_entries(payment_entry.payment_document, payment_entry.payment_entry) + bt_allocations = get_total_allocated_amount( + payment_entry.payment_document, payment_entry.payment_entry ) - return reconciled_bank_transactions + unallocated_amount = min( + transaction.unallocated_amount, + get_paid_amount(payment_entry, transaction.currency, gl_bank_account), + ) + unmatched_gles = len(gles) + latest_transaction = transaction + for gle in gles: + if gle["gl_account"] == gl_bank_account: + if gle["amount"] <= 0.0: + frappe.throw( + frappe._(f"Voucher {payment_entry.payment_entry} value is broken: {gle['amount']}") + ) + + unmatched_gles -= 1 + unallocated_amount = gle["amount"] + for a in bt_allocations: + if a["gl_account"] == gle["gl_account"]: + unallocated_amount = gle["amount"] - a["total"] + if frappe.utils.getdate(transaction.date) < a["latest_date"]: + latest_transaction = frappe.get_doc("Bank Transaction", a["latest_name"]) + else: + # Must be a Journal Entry affecting more than one bank + for a in bt_allocations: + if a["gl_account"] == gle["gl_account"] and a["total"] == gle["amount"]: + unmatched_gles -= 1 + + return unallocated_amount, unmatched_gles == 0, latest_transaction -def get_total_allocated_amount(payment_entry): - return frappe.db.sql( +def get_related_bank_gl_entries(doctype, docname): + # nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql + result = frappe.db.sql( """ SELECT - SUM(btp.allocated_amount) as allocated_amount, - bt.name + ABS(gle.credit_in_account_currency - gle.debit_in_account_currency) AS amount, + gle.account AS gl_account FROM - `tabBank Transaction Payments` as btp + `tabGL Entry` gle LEFT JOIN - `tabBank Transaction` bt ON bt.name=btp.parent + `tabAccount` ac ON ac.name=gle.account WHERE - btp.payment_document = %s - AND - btp.payment_entry = %s - AND - bt.docstatus = 1""", - (payment_entry.payment_document, payment_entry.payment_entry), + ac.account_type = 'Bank' + AND gle.voucher_type = %(doctype)s + AND gle.voucher_no = %(docname)s + AND is_cancelled = 0 + """, + dict(doctype=doctype, docname=docname), as_dict=True, ) + return result -def get_paid_amount(payment_entry, currency, bank_account): +def get_total_allocated_amount(doctype, docname): + """ + Gets the sum of allocations for a voucher on each bank GL account + along with the latest bank transaction name & date + NOTE: query may also include just saved vouchers/payments but with zero allocated_amount + """ + # nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql + result = frappe.db.sql( + """ + SELECT total, latest_name, latest_date, gl_account FROM ( + SELECT + ROW_NUMBER() OVER w AS rownum, + SUM(btp.allocated_amount) OVER(PARTITION BY ba.account) AS total, + FIRST_VALUE(bt.name) OVER w AS latest_name, + FIRST_VALUE(bt.date) OVER w AS latest_date, + ba.account AS gl_account + FROM + `tabBank Transaction Payments` btp + LEFT JOIN `tabBank Transaction` bt ON bt.name=btp.parent + LEFT JOIN `tabBank Account` ba ON ba.name=bt.bank_account + WHERE + btp.payment_document = %(doctype)s + AND btp.payment_entry = %(docname)s + AND bt.docstatus = 1 + WINDOW w AS (PARTITION BY ba.account ORDER BY bt.date desc) + ) temp + WHERE + rownum = 1 + """, + dict(doctype=doctype, docname=docname), + as_dict=True, + ) + for row in result: + # Why is this *sometimes* a byte string? + if isinstance(row["latest_name"], bytes): + row["latest_name"] = row["latest_name"].decode() + row["latest_date"] = frappe.utils.getdate(row["latest_date"]) + return result + + +def get_paid_amount(payment_entry, currency, gl_bank_account): if payment_entry.payment_document in ["Payment Entry", "Sales Invoice", "Purchase Invoice"]: paid_amount_field = "paid_amount" @@ -147,7 +283,7 @@ def get_paid_amount(payment_entry, currency, bank_account): elif payment_entry.payment_document == "Journal Entry": return frappe.db.get_value( "Journal Entry Account", - {"parent": payment_entry.payment_entry, "account": bank_account}, + {"parent": payment_entry.payment_entry, "account": gl_bank_account}, "sum(credit_in_account_currency)", ) @@ -166,6 +302,12 @@ def get_paid_amount(payment_entry, currency, bank_account): payment_entry.payment_document, payment_entry.payment_entry, "amount_paid" ) + elif payment_entry.payment_document == "Bank Transaction": + dep, wth = frappe.db.get_value( + "Bank Transaction", payment_entry.payment_entry, ("deposit", "withdrawal") + ) + return abs(flt(wth) - flt(dep)) + else: frappe.throw( "Please reconcile {0}: {1} manually".format( @@ -174,18 +316,55 @@ def get_paid_amount(payment_entry, currency, bank_account): ) -@frappe.whitelist() -def unclear_reference_payment(doctype, docname): - if frappe.db.exists(doctype, docname): - doc = frappe.get_doc(doctype, docname) - if doctype == "Sales Invoice": - frappe.db.set_value( - "Sales Invoice Payment", - dict(parenttype=doc.payment_document, parent=doc.payment_entry), - "clearance_date", - None, - ) - else: - frappe.db.set_value(doc.payment_document, doc.payment_entry, "clearance_date", None) +def set_voucher_clearance(doctype, docname, clearance_date, self): + if doctype in [ + "Payment Entry", + "Journal Entry", + "Purchase Invoice", + "Expense Claim", + "Loan Repayment", + "Loan Disbursement", + ]: + if ( + doctype == "Payment Entry" + and frappe.db.get_value("Payment Entry", docname, "payment_type") == "Internal Transfer" + and len(get_reconciled_bank_transactions(doctype, docname)) < 2 + ): + return + frappe.db.set_value(doctype, docname, "clearance_date", clearance_date) - return doc.payment_entry + elif doctype == "Sales Invoice": + frappe.db.set_value( + "Sales Invoice Payment", + dict(parenttype=doctype, parent=docname), + "clearance_date", + clearance_date, + ) + + elif doctype == "Bank Transaction": + # For when a second bank transaction has fixed another, e.g. refund + bt = frappe.get_doc(doctype, docname) + if clearance_date: + vouchers = [{"payment_doctype": "Bank Transaction", "payment_name": self.name}] + bt.add_payment_entries(vouchers) + else: + for pe in bt.payment_entries: + if pe.payment_document == self.doctype and pe.payment_entry == self.name: + bt.remove(pe) + bt.save() + break + + +def get_reconciled_bank_transactions(doctype, docname): + return frappe.get_all( + "Bank Transaction Payments", + filters={"payment_document": doctype, "payment_entry": docname}, + pluck="parent", + ) + + +@frappe.whitelist() +def unclear_reference_payment(doctype, docname, bt_name): + bt = frappe.get_doc("Bank Transaction", bt_name) + set_voucher_clearance(doctype, docname, None, bt) + return docname diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py index 38d69932f24..f44fad333cf 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py @@ -12,7 +12,7 @@ class PlaidConnector: def __init__(self, access_token=None): self.access_token = access_token self.settings = frappe.get_single("Plaid Settings") - self.products = ["auth", "transactions"] + self.products = ["transactions"] self.client_name = frappe.local.site self.client = plaid.Client( client_id=self.settings.plaid_client_id, diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js index 3740d049839..3ba6bb99873 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js @@ -47,7 +47,7 @@ erpnext.integrations.plaidLink = class plaidLink { } async init_config() { - this.product = ["auth", "transactions"]; + this.product = ["transactions"]; this.plaid_env = this.frm.doc.plaid_env; this.client_name = frappe.boot.sitename; this.token = await this.get_link_token(); diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py index 62ea85fc5d2..f3aa6a37935 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py @@ -70,7 +70,8 @@ def add_bank_accounts(response, bank, company): except TypeError: pass - bank = json.loads(bank) + if isinstance(bank, str): + bank = json.loads(bank) result = [] default_gl_account = get_default_bank_cash_account(company, "Bank") @@ -177,16 +178,15 @@ def sync_transactions(bank, bank_account): ) result = [] - for transaction in reversed(transactions): - result += new_bank_transaction(transaction) + if transactions: + for transaction in reversed(transactions): + result += new_bank_transaction(transaction) if result: last_transaction_date = frappe.db.get_value("Bank Transaction", result.pop(), "date") frappe.logger().info( - "Plaid added {} new Bank Transactions from '{}' between {} and {}".format( - len(result), bank_account, start_date, end_date - ) + f"Plaid added {len(result)} new Bank Transactions from '{bank_account}' between {start_date} and {end_date}" ) frappe.db.set_value( @@ -230,19 +230,20 @@ def new_bank_transaction(transaction): bank_account = frappe.db.get_value("Bank Account", dict(integration_id=transaction["account_id"])) - if float(transaction["amount"]) >= 0: - debit = 0 - credit = float(transaction["amount"]) + amount = float(transaction["amount"]) + if amount >= 0.0: + deposit = 0.0 + withdrawal = amount else: - debit = abs(float(transaction["amount"])) - credit = 0 + deposit = abs(amount) + withdrawal = 0.0 status = "Pending" if transaction["pending"] == "True" else "Settled" tags = [] try: tags += transaction["category"] - tags += ["Plaid Cat. {}".format(transaction["category_id"])] + tags += [f'Plaid Cat. {transaction["category_id"]}'] except KeyError: pass @@ -254,11 +255,18 @@ def new_bank_transaction(transaction): "date": getdate(transaction["date"]), "status": status, "bank_account": bank_account, - "deposit": debit, - "withdrawal": credit, + "deposit": deposit, + "withdrawal": withdrawal, "currency": transaction["iso_currency_code"], "transaction_id": transaction["transaction_id"], - "reference_number": transaction["payment_meta"]["reference_number"], + "transaction_type": ( + transaction["transaction_code"] or transaction["payment_meta"]["payment_method"] + ), + "reference_number": ( + transaction["check_number"] + or transaction["payment_meta"]["reference_number"] + or transaction["name"] + ), "description": transaction["name"], } ) @@ -271,7 +279,7 @@ def new_bank_transaction(transaction): result.append(new_transaction.name) except Exception: - frappe.throw(title=_("Bank transaction creation error")) + frappe.throw(_("Bank transaction creation error")) return result @@ -300,3 +308,26 @@ def enqueue_synchronization(): def get_link_token_for_update(access_token): plaid = PlaidConnector(access_token) return plaid.get_link_token(update_mode=True) + + +def get_company(bank_account_name): + from frappe.defaults import get_user_default + + company_names = frappe.db.get_all("Company", pluck="name") + if len(company_names) == 1: + return company_names[0] + if frappe.db.exists("Bank Account", bank_account_name): + return frappe.db.get_value("Bank Account", bank_account_name, "company") + company_default = get_user_default("Company") + if company_default: + return company_default + frappe.throw(_("Could not detect the Company for updating Bank Accounts")) + + +@frappe.whitelist() +def update_bank_account_ids(response): + data = json.loads(response) + institution_name = data["institution"]["name"] + bank = frappe.get_doc("Bank", institution_name).as_dict() + bank_account_name = f"{data['account']['name']} - {institution_name}" + return add_bank_accounts(response, bank, get_company(bank_account_name)) diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py index e8dc3e258f6..6d34a204cd2 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py @@ -125,6 +125,8 @@ class TestPlaidSettings(unittest.TestCase): "unofficial_currency_code": None, "name": "INTRST PYMNT", "transaction_type": "place", + "transaction_code": "direct debit", + "check_number": "3456789", "amount": -4.22, "location": { "city": None, diff --git a/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js b/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js index f7c19a1b7ff..0cda93880fa 100644 --- a/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js +++ b/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js @@ -182,6 +182,9 @@ erpnext.accounts.bank_reconciliation.DataTableManager = class DataTableManager { ); } else { this.transactions.splice(transaction_index, 1); + for (const [k, v] of Object.entries(this.transaction_dt_map)) { + if (v > transaction_index) this.transaction_dt_map[k] = v - 1; + } } this.datatable.refresh(this.transactions, this.columns); diff --git a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js index 911343d8b64..321b812de21 100644 --- a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js +++ b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js @@ -20,7 +20,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { doctype: "Bank Transaction", filters: { name: this.bank_transaction_name }, fieldname: [ - "date as reference_date", + "date", "deposit", "withdrawal", "currency", @@ -33,6 +33,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { "party", "unallocated_amount", "allocated_amount", + "transaction_type", ], }, callback: (r) => { @@ -41,11 +42,23 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { r.message.payment_entry = 1; r.message.journal_entry = 1; this.dialog.set_values(r.message); + this.copy_data_to_voucher(); this.dialog.show(); } }, }); } + + copy_data_to_voucher() { + let copied = { + reference_number: this.bank_transaction.reference_number || this.bank_transaction.description, + posting_date: this.bank_transaction.date, + reference_date: this.bank_transaction.date, + mode_of_payment: this.bank_transaction.transaction_type, + }; + this.dialog.set_values(copied); + } + get_linked_vouchers(document_types) { frappe.call({ method: @@ -75,10 +88,9 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { row[1], row[2], reference_date, - row[8], format_currency(row[3], row[9]), - row[6], row[4], + row[6], ]); }); this.get_dt_columns(); @@ -104,7 +116,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { { name: __("Document Name"), editable: false, - width: 150, + width: 1, }, { name: __("Reference Date"), @@ -112,25 +124,19 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { width: 120, }, { - name: "Posting Date", - editable: false, - width: 120, - }, - { - name: __("Amount"), + name: __("Remaining"), editable: false, width: 100, }, - { - name: __("Party"), - editable: false, - width: 120, - }, - { name: __("Reference Number"), editable: false, - width: 140, + width: 200, + }, + { + name: __("Party"), + editable: false, + width: 100, }, ]; } @@ -224,6 +230,16 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { fieldname: "exact_match", onchange: () => this.update_options(), }, + { + fieldname: "column_break_5", + fieldtype: "Column Break", + }, + { + fieldtype: "Check", + label: "Bank Transaction", + fieldname: "bank_transaction", + onchange: () => this.update_options(), + }, { fieldtype: "Section Break", fieldname: "section_break_1", @@ -289,7 +305,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { fieldtype: "Column Break", }, { - default: "Journal Entry Type", + default: "Bank Entry", fieldname: "journal_entry_type", fieldtype: "Select", label: "Journal Entry Type", @@ -364,7 +380,12 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { fieldtype: "Section Break", fieldname: "details_section", label: "Transaction Details", - collapsible: 1, + }, + { + fieldname: "date", + fieldtype: "Date", + label: "Date", + read_only: 1, }, { fieldname: "deposit", @@ -381,14 +402,14 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { read_only: 1, }, { - fieldname: "description", - fieldtype: "Small Text", - label: "Description", + fieldname: "column_break_17", + fieldtype: "Column Break", read_only: 1, }, { - fieldname: "column_break_17", - fieldtype: "Column Break", + fieldname: "description", + fieldtype: "Small Text", + label: "Description", read_only: 1, }, { @@ -398,7 +419,6 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { options: "Currency", read_only: 1, }, - { fieldname: "unallocated_amount", fieldtype: "Currency", @@ -593,4 +613,4 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { } } -}; \ No newline at end of file +};