diff --git a/banking/custom_fields.py b/banking/custom_fields.py index b88f496b..93fb10f7 100644 --- a/banking/custom_fields.py +++ b/banking/custom_fields.py @@ -88,6 +88,48 @@ def get_custom_fields(): fieldtype="Data", insert_after="transaction_id", ), + dict( + fieldname="reserved_voucher_type", + label=_("Reserved Voucher Type"), + fieldtype="Link", + options="DocType", + insert_after="subtransaction_id", + read_only=1, + allow_on_submit=1, + no_copy=1, + ), + dict( + fieldname="reserved_voucher", + label=_("Reserved Voucher"), + fieldtype="Dynamic Link", + options="reserved_voucher_type", + insert_after="reserved_voucher_type", + read_only=1, + allow_on_submit=1, + no_copy=1, + ), + ], + "Journal Entry": [ + dict( + fieldname="created_from_bank_transaction", + label=_("Created From Bank Transaction"), + fieldtype="Link", + options="Bank Transaction", + insert_after="cheque_no", + read_only=1, + no_copy=1, + ), + ], + "Payment Entry": [ + dict( + fieldname="created_from_bank_transaction", + label=_("Created From Bank Transaction"), + fieldtype="Link", + options="Bank Transaction", + insert_after="reference_no", + read_only=1, + no_copy=1, + ), ], "Bank Account": [ dict( diff --git a/banking/hooks.py b/banking/hooks.py index 1bc41458..71ce06f4 100644 --- a/banking/hooks.py +++ b/banking/hooks.py @@ -123,6 +123,14 @@ "before_submit": "banking.overrides.bank_transaction.before_submit", "on_cancel": "banking.overrides.bank_transaction.on_cancel", }, + "Journal Entry": { + "on_submit": "banking.overrides.voucher_reconciliation.reconcile_created_from_bank_transaction", + "on_trash": "banking.overrides.voucher_reconciliation.release_bank_transaction_reservation", + }, + "Payment Entry": { + "on_submit": "banking.overrides.voucher_reconciliation.reconcile_created_from_bank_transaction", + "on_trash": "banking.overrides.voucher_reconciliation.release_bank_transaction_reservation", + }, "Bank Account": { "before_validate": "banking.overrides.bank_account.before_validate", "validate": "banking.overrides.bank_account.validate", diff --git a/banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.js b/banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.js index 35cef015..a00086b4 100644 --- a/banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.js +++ b/banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.js @@ -273,6 +273,9 @@ frappe.ui.form.on("Bank Reconciliation Tool Beta", { build_reconciliation_area: function (frm) { frappe.require("bank_reconciliation_beta.bundle.js", () => { + if (frm.panel_manager?.cleanup_voucher_watches) { + frm.panel_manager.cleanup_voucher_watches(); + } frm.panel_manager = new erpnext.accounts.bank_reconciliation.PanelManager( { frm: frm, diff --git a/banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.py b/banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.py index f1e3c80a..a1ea4017 100644 --- a/banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.py +++ b/banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/bank_reconciliation_tool_beta.py @@ -114,7 +114,7 @@ def get_bank_transactions( to_date: str | datetime.date | None = None, order_by: str | None = "date asc", ) -> tuple[dict[str, Any], ...] | None: - """Return bank transactions for a bank account""" + """Return bank transactions for a bank account, including reserved drafts.""" filters: list[list] = [ ["docstatus", "=", 1], ["status", "not in", ["Reconciled", "Cancelled"]], @@ -156,12 +156,36 @@ def get_bank_transactions( "bank_party_name", "bank_party_account_number", "bank_party_iban", + "reserved_voucher_type", + "reserved_voucher", ], filters=filters, order_by=get_bank_transaction_order_by(order_by), ) +def _reserve_bank_transaction(bank_transaction: CustomBankTransaction, voucher_type: str, voucher_name: str): + if bank_transaction.reserved_voucher: + frappe.throw( + _( + "Bank Transaction {0} is already reserved by draft {1} {2}. " + "Submit or delete that voucher before creating another." + ).format( + frappe.bold(bank_transaction.name), + _(bank_transaction.reserved_voucher_type), + frappe.bold(bank_transaction.reserved_voucher), + ) + ) + + bank_transaction.db_set( + { + "reserved_voucher_type": voucher_type, + "reserved_voucher": voucher_name, + }, + update_modified=False, + ) + + @frappe.whitelist(methods=["POST"]) def create_journal_entry_bts( bank_transaction_name: str, @@ -188,8 +212,10 @@ def create_journal_entry_bts( if isinstance(allow_edit, str): allow_edit = sbool(allow_edit) - bank_transaction: CustomBankTransaction = frappe.get_doc("Bank Transaction", bank_transaction_name) - bank_transaction.check_permission("read") + bank_transaction: CustomBankTransaction = frappe.get_doc( + "Bank Transaction", bank_transaction_name, for_update=allow_edit + ) + bank_transaction.check_permission("write" if allow_edit else "read") if bank_transaction.deposit and bank_transaction.withdrawal: frappe.throw(_("Cannot create Journal Entry for a Bank Transaction with both Deposit and Withdrawal")) @@ -226,6 +252,8 @@ def create_journal_entry_bts( "user_remark": bank_transaction.description, } ) + if allow_edit: + journal_entry.created_from_bank_transaction = bank_transaction.name account_rows = [ { @@ -259,6 +287,7 @@ def create_journal_entry_bts( journal_entry.insert() if allow_edit: + _reserve_bank_transaction(bank_transaction, "Journal Entry", journal_entry.name) return journal_entry # Return saved document # This check happens here because the user should be able to make @@ -303,12 +332,11 @@ def create_payment_entry_bts( allow_edit = sbool(allow_edit) # Create a new payment entry based on the bank transaction - bank_transaction: CustomBankTransaction = frappe.db.get_values( - "Bank Transaction", - bank_transaction_name, - fieldname=["name", "unallocated_amount", "deposit", "bank_account"], - as_dict=True, - )[0] + bank_transaction: CustomBankTransaction = frappe.get_doc( + "Bank Transaction", bank_transaction_name, for_update=allow_edit + ) + bank_transaction.check_permission("write" if allow_edit else "read") + paid_amount = bank_transaction.unallocated_amount payment_type = "Receive" if bank_transaction.deposit > 0.0 else "Pay" @@ -328,6 +356,8 @@ def create_payment_entry_bts( payment_entry: PaymentEntry = frappe.new_doc("Payment Entry") payment_entry.update(payment_entry_dict) + if allow_edit: + payment_entry.created_from_bank_transaction = bank_transaction.name if mode_of_payment: payment_entry.mode_of_payment = mode_of_payment @@ -348,6 +378,7 @@ def create_payment_entry_bts( payment_entry.insert() if allow_edit: + _reserve_bank_transaction(bank_transaction, "Payment Entry", payment_entry.name) return payment_entry # Return saved document payment_entry.submit() @@ -455,6 +486,9 @@ def auto_reconcile_vouchers( company=company, bank=bank, bank_account=bank_account, from_date=from_date, to_date=to_date ) for transaction in bank_transactions: + # Skip transactions reserved by an Edit-in-Full-Page draft voucher + if transaction.reserved_voucher: + continue linked_payments = get_linked_payments( transaction.name, ["payment_entry", "journal_entry"], diff --git a/banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/test_bank_reconciliation_tool_beta.py b/banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/test_bank_reconciliation_tool_beta.py index 6384a3bb..670f11f1 100644 --- a/banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/test_bank_reconciliation_tool_beta.py +++ b/banking/klarna_kosma_integration/doctype/bank_reconciliation_tool_beta/test_bank_reconciliation_tool_beta.py @@ -27,6 +27,7 @@ bulk_reconcile_vouchers, create_journal_entry_bts, create_payment_entry_bts, + get_bank_transactions, get_linked_payments, ) @@ -783,6 +784,11 @@ def test_split_jv_match_against_transaction(self): allow_edit=True, ) + # Detach from Edit-in-Full-Page auto-reconcile so matching can be tested independently + journal_entry.db_set("created_from_bank_transaction", None) + journal_entry.created_from_bank_transaction = None + bt.db_set({"reserved_voucher_type": None, "reserved_voucher": None}) + # Split the JV Row into two journal_entry.accounts[1].debit_in_account_currency = 100 journal_entry.append( @@ -883,6 +889,9 @@ def test_cheque_number_linked_journal_entry_is_excluded_from_matches(self): second_account=frappe.db.get_value("Company", bt.company, "default_receivable_account"), allow_edit=True, ) + journal_entry.db_set("created_from_bank_transaction", None) + journal_entry.created_from_bank_transaction = None + bt.db_set({"reserved_voucher_type": None, "reserved_voucher": None}) journal_entry.submit() self.assertFalse(journal_entry.is_system_generated) @@ -1559,6 +1568,256 @@ def test_included_fee_deposit_ignores_fee_when_disabled(self): self.assertEqual(si.outstanding_amount, 0) self.assertEqual(si_with_fee.outstanding_amount, 80) + def _create_draft_journal_entry(self, bt, **kwargs): + defaults = { + "bank_transaction_name": bt.name, + "party_type": "Customer", + "party": self.customer, + "posting_date": bt.date, + "reference_number": bt.reference_number or bt.name, + "reference_date": bt.date, + "entry_type": "Bank Entry", + "second_account": frappe.db.get_value("Company", bt.company, "default_receivable_account"), + "allow_edit": True, + } + defaults.update(kwargs) + return create_journal_entry_bts(**defaults) + + def _create_draft_payment_entry(self, bt, **kwargs): + defaults = { + "bank_transaction_name": bt.name, + "reference_number": bt.reference_number or bt.name, + "reference_date": bt.date, + "party_type": "Customer" if bt.deposit else "Supplier", + "party": self.customer if bt.deposit else create_supplier(), + "posting_date": bt.date, + "allow_edit": True, + } + defaults.update(kwargs) + return create_payment_entry_bts(**defaults) + + def test_edit_in_full_page_journal_entry_reserves_transaction(self): + bt = create_bank_transaction(deposit=200, reference_no="reserve-je", bank_account=self.bank_account) + journal_entry = self._create_draft_journal_entry(bt) + + bt.reload() + journal_entry.reload() + + self.assertEqual(journal_entry.created_from_bank_transaction, bt.name) + self.assertEqual(bt.reserved_voucher_type, "Journal Entry") + self.assertEqual(bt.reserved_voucher, journal_entry.name) + + transactions = get_bank_transactions(bank_account=self.bank_account) + reserved_row = next(row for row in transactions if row.name == bt.name) + self.assertEqual(reserved_row.reserved_voucher, journal_entry.name) + self.assertEqual(reserved_row.reserved_voucher_type, "Journal Entry") + + def test_edit_in_full_page_payment_entry_reserves_transaction(self): + bt = create_bank_transaction(deposit=150, reference_no="reserve-pe", bank_account=self.bank_account) + payment_entry = self._create_draft_payment_entry(bt) + + bt.reload() + payment_entry.reload() + + self.assertEqual(payment_entry.created_from_bank_transaction, bt.name) + self.assertEqual(bt.reserved_voucher_type, "Payment Entry") + self.assertEqual(bt.reserved_voucher, payment_entry.name) + + transactions = get_bank_transactions(bank_account=self.bank_account) + reserved_row = next(row for row in transactions if row.name == bt.name) + self.assertEqual(reserved_row.reserved_voucher, payment_entry.name) + self.assertEqual(reserved_row.reserved_voucher_type, "Payment Entry") + + def test_second_draft_against_reserved_transaction_is_rejected(self): + bt = create_bank_transaction(deposit=200, reference_no="second-draft", bank_account=self.bank_account) + first = self._create_draft_journal_entry(bt) + + with self.assertRaises(frappe.ValidationError): + self._create_draft_journal_entry(bt) + + bt.reload() + self.assertEqual(bt.reserved_voucher, first.name) + + def test_unrelated_reconciliation_against_reserved_transaction_is_rejected(self): + bt = create_bank_transaction( + deposit=200, reference_no="unrelated-reco", bank_account=self.bank_account + ) + self._create_draft_journal_entry(bt) + + si = create_sales_invoice( + rate=100, + warehouse="Finished Goods - _TC", + customer=self.customer, + cost_center="Main - _TC", + item="Reco Item", + ) + + with self.assertRaises(frappe.ValidationError): + bulk_reconcile_vouchers( + bt.name, + json.dumps([{"payment_doctype": "Sales Invoice", "payment_name": si.name}]), + ) + + bt.reload() + self.assertTrue(bt.reserved_voucher) + self.assertFalse(bt.payment_entries) + + def test_delete_draft_releases_reservation(self): + bt = create_bank_transaction(deposit=200, reference_no="delete-draft", bank_account=self.bank_account) + journal_entry = self._create_draft_journal_entry(bt) + journal_entry.delete() + + bt.reload() + self.assertFalse(bt.reserved_voucher) + self.assertFalse(bt.reserved_voucher_type) + + transactions = get_bank_transactions(bank_account=self.bank_account) + row = next(row for row in transactions if row.name == bt.name) + self.assertFalse(row.reserved_voucher) + self.assertFalse(row.reserved_voucher_type) + + def test_durable_provenance_survives_reload(self): + bt = create_bank_transaction(deposit=200, reference_no="provenance", bank_account=self.bank_account) + journal_entry = self._create_draft_journal_entry(bt) + name = journal_entry.name + + reloaded = frappe.get_doc("Journal Entry", name) + self.assertEqual(reloaded.created_from_bank_transaction, bt.name) + + def test_submit_draft_journal_entry_fully_reconciles(self): + bt = create_bank_transaction( + deposit=200, reference_no="submit-je-full", bank_account=self.bank_account + ) + journal_entry = self._create_draft_journal_entry(bt) + journal_entry.submit() + + bt.reload() + journal_entry.reload() + + self.assertEqual(journal_entry.created_from_bank_transaction, bt.name) + self.assertFalse(bt.reserved_voucher) + self.assertEqual(bt.status, "Reconciled") + self.assertEqual(len(bt.payment_entries), 1) + self.assertEqual(bt.payment_entries[0].payment_entry, journal_entry.name) + self.assertEqual(bt.payment_entries[0].allocated_amount, 200) + + def test_submit_draft_payment_entry_fully_reconciles(self): + bt = create_bank_transaction( + deposit=175, reference_no="submit-pe-full", bank_account=self.bank_account + ) + payment_entry = self._create_draft_payment_entry(bt) + payment_entry.submit() + + bt.reload() + payment_entry.reload() + + self.assertEqual(payment_entry.created_from_bank_transaction, bt.name) + self.assertFalse(bt.reserved_voucher) + self.assertEqual(bt.status, "Reconciled") + self.assertEqual(bt.payment_entries[0].payment_entry, payment_entry.name) + self.assertEqual(bt.payment_entries[0].allocated_amount, 175) + + def test_submit_draft_journal_entry_partially_reconciles(self): + bt = create_bank_transaction( + deposit=200, reference_no="submit-je-partial", bank_account=self.bank_account + ) + journal_entry = self._create_draft_journal_entry(bt) + + journal_entry.accounts[0].credit_in_account_currency = 80 + journal_entry.accounts[1].debit_in_account_currency = 80 + journal_entry.save() + journal_entry.submit() + + bt.reload() + self.assertFalse(bt.reserved_voucher) + self.assertEqual(bt.status, "Unreconciled") + self.assertEqual(bt.unallocated_amount, 120) + self.assertEqual(bt.payment_entries[0].allocated_amount, 80) + + def test_submit_rejects_wrong_bank_account_on_journal_entry(self): + bt = create_bank_transaction( + deposit=200, reference_no="wrong-account", bank_account=self.bank_account + ) + journal_entry = self._create_draft_journal_entry(bt) + + other_gl = create_bank_gl_account("_Test Other Reco Bank") + journal_entry.accounts[1].account = other_gl + journal_entry.save() + + frappe.db.savepoint("before_failed_submit") + with self.assertRaises(frappe.ValidationError): + journal_entry.submit() + frappe.db.rollback(save_point="before_failed_submit") + + bt.reload() + journal_entry.reload() + self.assertEqual(bt.reserved_voucher, journal_entry.name) + self.assertEqual(journal_entry.docstatus, 0) + self.assertFalse(bt.payment_entries) + + def test_submit_rejects_opposite_direction_payment_entry(self): + bt = create_bank_transaction( + deposit=200, reference_no="wrong-direction", bank_account=self.bank_account + ) + payment_entry = self._create_draft_payment_entry(bt) + + # Flip to Pay against a deposit transaction + company_account = frappe.get_value("Bank Account", bt.bank_account, "account") + payment_entry.payment_type = "Pay" + payment_entry.party_type = "Supplier" + payment_entry.party = create_supplier("_Test Reco Direction Supplier") + payment_entry.paid_from = company_account + payment_entry.paid_to = frappe.db.get_value("Company", bt.company, "default_payable_account") + payment_entry.save() + + frappe.db.savepoint("before_failed_submit") + with self.assertRaises(frappe.ValidationError): + payment_entry.submit() + frappe.db.rollback(save_point="before_failed_submit") + + bt.reload() + payment_entry.reload() + self.assertEqual(bt.reserved_voucher, payment_entry.name) + self.assertEqual(payment_entry.docstatus, 0) + + def test_submit_rejects_amount_above_unallocated(self): + bt = create_bank_transaction(deposit=200, reference_no="oversized", bank_account=self.bank_account) + journal_entry = self._create_draft_journal_entry(bt) + + journal_entry.accounts[0].credit_in_account_currency = 250 + journal_entry.accounts[1].debit_in_account_currency = 250 + journal_entry.save() + + frappe.db.savepoint("before_failed_submit") + with self.assertRaises(frappe.ValidationError): + journal_entry.submit() + frappe.db.rollback(save_point="before_failed_submit") + + bt.reload() + journal_entry.reload() + self.assertEqual(bt.reserved_voucher, journal_entry.name) + self.assertEqual(journal_entry.docstatus, 0) + + def test_failed_reconciliation_rolls_back_without_losing_reservation(self): + bt = create_bank_transaction(deposit=200, reference_no="failed-reco", bank_account=self.bank_account) + journal_entry = self._create_draft_journal_entry(bt) + + frappe.db.savepoint("before_failed_submit") + with patch( + "banking.overrides.bank_transaction.CustomBankTransaction.allocate_payment_entries", + side_effect=frappe.ValidationError("forced reconciliation failure"), + ): + with self.assertRaises(frappe.ValidationError): + journal_entry.submit() + frappe.db.rollback(save_point="before_failed_submit") + + bt.reload() + journal_entry.reload() + self.assertEqual(bt.reserved_voucher, journal_entry.name) + self.assertEqual(journal_entry.docstatus, 0) + self.assertFalse(bt.payment_entries) + self.assertEqual(journal_entry.created_from_bank_transaction, bt.name) + def get_pe_references(vouchers: list): return frappe.get_all( @@ -1664,6 +1923,14 @@ def create_bank(bank_name: str = "Citi Bank", swift_number: str = "CITIUS33"): def create_bank_gl_account(account_name: str = "_Test Bank - _TC", currency: str = "INR") -> str: + existing = frappe.db.exists("Account", {"account_name": account_name, "company": "_Test Company"}) + if existing: + return existing + + # Account names that already include the company suffix are stored as-is + if frappe.db.exists("Account", account_name): + return account_name + gl_account = frappe.get_doc( { "doctype": "Account", diff --git a/banking/overrides/bank_transaction.py b/banking/overrides/bank_transaction.py index 9f487da8..96da1207 100644 --- a/banking/overrides/bank_transaction.py +++ b/banking/overrides/bank_transaction.py @@ -41,12 +41,35 @@ def add_payment_entries(self, vouchers: list, reconcile_multi_party: bool = Fals if self.unallocated_amount <= 0.0: frappe.throw(frappe._("Bank Transaction {0} is already fully reconciled").format(self.name)) + self.assert_reservation_allows(vouchers) + pe_length_before = len(self.payment_entries) self.reconcile_paid_vouchers(vouchers) if len(self.payment_entries) != pe_length_before: self.save() # runs on_update_after_submit + def assert_reservation_allows(self, vouchers: list): + """Reject payment entries other than the reserved voucher while a reservation is active.""" + if not self.reserved_voucher: + return + + for voucher in vouchers: + voucher_type, voucher_name = voucher["payment_doctype"], voucher["payment_name"] + if voucher_type == self.reserved_voucher_type and voucher_name == self.reserved_voucher: + continue + + frappe.throw( + _( + "Bank Transaction {0} is reserved by draft {1} {2}. " + "Submit or delete that voucher before reconciling other entries." + ).format( + frappe.bold(self.name), + _(self.reserved_voucher_type), + frappe.bold(self.reserved_voucher), + ) + ) + def validate_period_closing(self): """ Check if the Bank Transaction date is after the latest period closing date. @@ -108,7 +131,9 @@ def get_rounded(self, fieldname: str) -> float: def on_update_after_submit(doc, event): - """Validate if the Bank Transaction is over-allocated.""" + """Validate reservation and over-allocation after submit.""" + _validate_new_payment_entries_against_reservation(doc) + to_allocate = flt(doc.withdrawal or doc.deposit) for entry in doc.payment_entries: to_allocate -= flt(entry.allocated_amount) @@ -122,6 +147,33 @@ def on_update_after_submit(doc, event): ) +def _validate_new_payment_entries_against_reservation(doc): + if not doc.reserved_voucher: + return + + before = doc.get_doc_before_save() + before_keys = { + (entry.payment_document, entry.payment_entry) for entry in (before.payment_entries if before else []) + } + reserved_key = (doc.reserved_voucher_type, doc.reserved_voucher) + + for entry in doc.payment_entries: + key = (entry.payment_document, entry.payment_entry) + if key in before_keys or key == reserved_key: + continue + + frappe.throw( + _( + "Bank Transaction {0} is reserved by draft {1} {2}. " + "Submit or delete that voucher before reconciling other entries." + ).format( + frappe.bold(doc.name), + _(doc.reserved_voucher_type), + frappe.bold(doc.reserved_voucher), + ) + ) + + def has_zero_transaction_amount_with_included_fee(doc: CustomBankTransaction) -> bool: return ( doc.get_rounded("deposit") == 0 diff --git a/banking/overrides/voucher_reconciliation.py b/banking/overrides/voucher_reconciliation.py new file mode 100644 index 00000000..6a9bc6e9 --- /dev/null +++ b/banking/overrides/voucher_reconciliation.py @@ -0,0 +1,138 @@ +import frappe +from frappe import _ +from frappe.utils import flt + + +def reconcile_created_from_bank_transaction(doc, method=None): + """Reconcile a voucher submitted from Edit-in-Full-Page with its reserved Bank Transaction.""" + if not doc.get("created_from_bank_transaction"): + return + + bank_transaction = frappe.get_doc("Bank Transaction", doc.created_from_bank_transaction, for_update=True) + + if bank_transaction.reserved_voucher_type != doc.doctype or bank_transaction.reserved_voucher != doc.name: + frappe.throw( + _("Bank Transaction {0} is not reserved for {1} {2}.").format( + frappe.bold(bank_transaction.name), _(doc.doctype), frappe.bold(doc.name) + ) + ) + + amount = get_voucher_bank_amount(doc, bank_transaction) + + vouchers = [ + { + "payment_doctype": doc.doctype, + "payment_name": doc.name, + "amount": amount, + } + ] + + if bank_transaction.unallocated_amount <= 0.0: + frappe.throw(_("Bank Transaction {0} is already fully reconciled").format(bank_transaction.name)) + + bank_transaction.assert_reservation_allows(vouchers) + bank_transaction.reconcile_paid_vouchers(vouchers) + bank_transaction.reserved_voucher_type = None + bank_transaction.reserved_voucher = None + bank_transaction.save() + + +def release_bank_transaction_reservation(doc, method=None): + """Clear the Bank Transaction reservation when the draft voucher is deleted.""" + if not doc.get("created_from_bank_transaction"): + return + + if not frappe.db.exists("Bank Transaction", doc.created_from_bank_transaction): + return + + bank_transaction = frappe.get_doc("Bank Transaction", doc.created_from_bank_transaction, for_update=True) + + if ( + bank_transaction.reserved_voucher_type == doc.doctype + and bank_transaction.reserved_voucher == doc.name + ): + bank_transaction.db_set( + { + "reserved_voucher_type": None, + "reserved_voucher": None, + }, + update_modified=False, + ) + + +def get_voucher_bank_amount(doc, bank_transaction) -> float: + """Validate the voucher affects the expected bank GL account and return its bank-side amount.""" + bank_gl_account = frappe.get_value("Bank Account", bank_transaction.bank_account, "account") + is_deposit = flt(bank_transaction.deposit) > 0 + + if doc.doctype == "Journal Entry": + amount = _get_journal_entry_bank_amount(doc, bank_gl_account, is_deposit) + elif doc.doctype == "Payment Entry": + amount = _get_payment_entry_bank_amount(doc, bank_gl_account, is_deposit) + else: + frappe.throw(_("Unsupported voucher type {0}.").format(doc.doctype)) + + if amount <= 0: + frappe.throw( + _( + "The voucher does not affect bank account {0} in the expected direction, or the amount is zero." + ).format(frappe.bold(bank_gl_account)) + ) + + if amount > flt(bank_transaction.unallocated_amount): + frappe.throw( + _("Voucher amount {0} exceeds the unallocated amount {1} of Bank Transaction {2}.").format( + frappe.bold(amount), + frappe.bold(bank_transaction.unallocated_amount), + frappe.bold(bank_transaction.name), + ) + ) + + return amount + + +def _get_journal_entry_bank_amount(doc, bank_gl_account: str, is_deposit: bool) -> float: + debit = credit = 0.0 + for row in doc.accounts: + if row.account != bank_gl_account: + continue + debit += flt(row.debit_in_account_currency) + credit += flt(row.credit_in_account_currency) + + if is_deposit: + return debit - credit + return credit - debit + + +def _get_payment_entry_bank_amount(doc, bank_gl_account: str, is_deposit: bool) -> float: + if doc.payment_type == "Receive": + if not is_deposit: + frappe.throw( + _("Payment Entry of type {0} cannot reconcile a withdrawal Bank Transaction.").format( + frappe.bold(_("Receive")) + ) + ) + if doc.paid_to != bank_gl_account: + frappe.throw( + _("Payment Entry bank account {0} does not match Bank Transaction account {1}.").format( + frappe.bold(doc.paid_to), frappe.bold(bank_gl_account) + ) + ) + return flt(doc.received_amount) + + if doc.payment_type == "Pay": + if is_deposit: + frappe.throw( + _("Payment Entry of type {0} cannot reconcile a deposit Bank Transaction.").format( + frappe.bold(_("Pay")) + ) + ) + if doc.paid_from != bank_gl_account: + frappe.throw( + _("Payment Entry bank account {0} does not match Bank Transaction account {1}.").format( + frappe.bold(doc.paid_from), frappe.bold(bank_gl_account) + ) + ) + return flt(doc.paid_amount) + + frappe.throw(_("Unsupported Payment Entry type {0}.").format(doc.payment_type)) diff --git a/banking/patches.txt b/banking/patches.txt index ffa586f4..21aa596b 100644 --- a/banking/patches.txt +++ b/banking/patches.txt @@ -12,4 +12,5 @@ execute:from banking.install import insert_custom_records; insert_custom_records banking.patches.set_voucher_matching_defaults banking.patches.download_batch_transactions banking.patches.truncate_legacy_filter_hidden_flag +banking.patches.add_voucher_reservation_fields execute:from banking.install import make_property_setters; make_property_setters() diff --git a/banking/patches/add_voucher_reservation_fields.py b/banking/patches/add_voucher_reservation_fields.py new file mode 100644 index 00000000..d7b3170e --- /dev/null +++ b/banking/patches/add_voucher_reservation_fields.py @@ -0,0 +1,7 @@ +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + +from banking.custom_fields import get_custom_fields + + +def execute(): + create_custom_fields(get_custom_fields()) diff --git a/banking/public/js/bank_reconciliation_beta/actions_panel/actions_panel_manager.js b/banking/public/js/bank_reconciliation_beta/actions_panel/actions_panel_manager.js index 7cfb78cd..ea3094e5 100644 --- a/banking/public/js/bank_reconciliation_beta/actions_panel/actions_panel_manager.js +++ b/banking/public/js/bank_reconciliation_beta/actions_panel/actions_panel_manager.js @@ -10,10 +10,15 @@ erpnext.accounts.bank_reconciliation.ActionsPanelManager = class ActionsPanelMan this.init_actions_container(); this.render_tabs(); - // Default to last selected tab - this.$actions_container - .find("#" + this.panel_manager.actions_tab) - .trigger("click"); + // Default to last selected tab (Match is unavailable for reserved drafts) + let default_tab = this.panel_manager.actions_tab; + if ( + this.transaction.reserved_voucher && + default_tab === "match_voucher-tab" + ) { + default_tab = "create_voucher-tab"; + } + this.$actions_container.find("#" + default_tab).trigger("click"); } init_actions_container() { @@ -46,9 +51,7 @@ erpnext.accounts.bank_reconciliation.ActionsPanelManager = class ActionsPanelMan this.tabs_list_ul = this.$actions_container.find(".form-tabs"); this.$tab_content = this.$actions_container.find(".tab-content"); - // Remove any listeners from previous tabs - frappe.realtime.off("doc_update"); - + const is_reserved = !!this.transaction.reserved_voucher; const tabs = [ { tab_name: "details", @@ -62,18 +65,6 @@ erpnext.accounts.bank_reconciliation.ActionsPanelManager = class ActionsPanelMan }); }, }, - { - tab_name: "match_voucher", - tab_label: __("Match Voucher"), - make_tab: () => { - return new erpnext.accounts.bank_reconciliation.MatchTab({ - actions_panel: this, - transaction: this.transaction, - panel_manager: this.panel_manager, - frm: this.frm, - }); - }, - }, { tab_name: "create_voucher", tab_label: __("Create Voucher"), @@ -94,6 +85,21 @@ erpnext.accounts.bank_reconciliation.ActionsPanelManager = class ActionsPanelMan }, ]; + if (!is_reserved) { + tabs.splice(1, 0, { + tab_name: "match_voucher", + tab_label: __("Match Voucher"), + make_tab: () => { + return new erpnext.accounts.bank_reconciliation.MatchTab({ + actions_panel: this, + transaction: this.transaction, + panel_manager: this.panel_manager, + frm: this.frm, + }); + }, + }); + } + for (const { tab_name, tab_label, make_tab } of tabs) { this.add_tab(tab_name, tab_label); diff --git a/banking/public/js/bank_reconciliation_beta/actions_panel/create_tab.js b/banking/public/js/bank_reconciliation_beta/actions_panel/create_tab.js index b1afd176..66294201 100644 --- a/banking/public/js/bank_reconciliation_beta/actions_panel/create_tab.js +++ b/banking/public/js/bank_reconciliation_beta/actions_panel/create_tab.js @@ -15,6 +15,11 @@ erpnext.accounts.bank_reconciliation.CreateTab = class CreateTab { make() { this.panel_manager.actions_tab = "create_voucher-tab"; + if (this.transaction.reserved_voucher) { + this.render_reserved_draft_link(); + return; + } + this.create_field_group = new frappe.ui.FieldGroup({ fields: this.get_create_tab_fields(), body: this.actions_panel.$tab_content, @@ -24,6 +29,31 @@ erpnext.accounts.bank_reconciliation.CreateTab = class CreateTab { this.create_field_group.refresh_section_collapse(); } + render_reserved_draft_link() { + const voucher_type = this.transaction.reserved_voucher_type; + const voucher_name = this.transaction.reserved_voucher; + this.panel_manager.watch_voucher_until_settled(voucher_type, voucher_name); + this.actions_panel.$tab_content.append(` +
+

+ ${__( + "A draft {0} is already linked to this transaction. Open it to continue editing or submit.", + [frappe.utils.escape_html(__(voucher_type))], + )} +

+ +
+ `); + this.actions_panel.$tab_content + .find(".open-reserved-draft") + .on("click", () => { + frappe.open_in_new_tab = true; + frappe.set_route("Form", voucher_type, voucher_name); + }); + } + create_voucher() { let values = this.create_field_group.get_values(); let document_type = values.document_type; @@ -44,15 +74,9 @@ erpnext.accounts.bank_reconciliation.CreateTab = class CreateTab { let doctype = doc[0].doctype, docname = doc[0].name; - // Reconcile and update the view - // when the voucher is submitted in another tab - frappe.socketio.doc_subscribe(doctype, docname); - frappe.realtime.off("doc_update"); - frappe.realtime.on("doc_update", (data) => { - if (data.doctype === doctype && data.name === docname) { - this.reconcile_new_voucher(doctype, docname); - } - }); + // Reload so the row stays visible with the reserved Draft badge + this.panel_manager.reload_transactions(); + this.panel_manager.watch_voucher_until_settled(doctype, docname); frappe.open_in_new_tab = true; frappe.set_route("Form", doctype, docname); @@ -130,48 +154,6 @@ erpnext.accounts.bank_reconciliation.CreateTab = class CreateTab { }); } - reconcile_new_voucher(doctype, docname) { - // If no response, newly created doc is in draft state - // If deleted in response, newly created doc is deleted - // If doc object in response, newly created doc is submitted (can be reconciled) - frappe.call({ - method: - "banking.klarna_kosma_integration.doctype.bank_reconciliation_tool_beta.bank_reconciliation_tool_beta.reconcile_voucher", - args: { - transaction_name: this.transaction.name, - amount: this.transaction.unallocated_amount, - voucher_type: doctype, - voucher_name: docname, - }, - callback: (response) => { - if (response.exc) { - frappe.show_alert({ - message: __("Failed to reconcile new {0} against {1}", [ - doctype, - this.transaction.name, - ]), - indicator: "red", - }); - return; - } else if ( - response.message && - Object.keys(response.message).length > 0 - ) { - if (response.message.deleted) { - frappe.realtime.off("doc_update"); - return; - } - - this.actions_panel.after_transaction_reconcile( - response.message, - true, - doctype, - ); - } - }, - }); - } - get_split_accounting_dimension_fields() { const company_defaults = (this.accounting_dimension_defaults || {})[this.company] || {}; diff --git a/banking/public/js/bank_reconciliation_beta/panel_manager.js b/banking/public/js/bank_reconciliation_beta/panel_manager.js index 55976661..6bf2a4e3 100644 --- a/banking/public/js/bank_reconciliation_beta/panel_manager.js +++ b/banking/public/js/bank_reconciliation_beta/panel_manager.js @@ -57,6 +57,7 @@ erpnext.accounts.bank_reconciliation.PanelManager = class PanelManager { .find(".panel-container"); this.render_panels(); + this.sync_reserved_voucher_watches(); } /** Re-fetch and re-render the transaction list (e.g. after sort change). */ @@ -68,11 +69,20 @@ erpnext.accounts.bank_reconciliation.PanelManager = class PanelManager { this.active_transaction = null; this.$panel_wrapper.empty(); this.render_no_transactions(); + this.sync_reserved_voucher_watches(); return; } - this.$list_container.empty(); - this.render_transactions_list(); + if (!this.$list_container?.length) { + this.$panel_wrapper.empty(); + this.set_actions_panel_default_states(); + this.render_list_panel(); + } else { + this.$list_container.empty(); + this.render_transactions_list(); + } + + this.sync_reserved_voucher_watches(); const $row = active_name ? this.$list_container.find("#" + active_name) @@ -97,7 +107,7 @@ erpnext.accounts.bank_reconciliation.PanelManager = class PanelManager { } async get_bank_transactions() { - let transactions = await frappe + const transactions = await frappe .call({ method: "banking.klarna_kosma_integration.doctype.bank_reconciliation_tool_beta.bank_reconciliation_tool_beta.get_bank_transactions", @@ -113,7 +123,8 @@ erpnext.accounts.bank_reconciliation.PanelManager = class PanelManager { freeze_message: __("Fetching Bank Transactions"), }) .then((response) => response.message); - return transactions; + + return transactions || []; } render_panels() { @@ -174,6 +185,207 @@ erpnext.accounts.bank_reconciliation.PanelManager = class PanelManager { }); } + /** + * Watch a reserved draft voucher until it is submitted or deleted, then reload. + * Uses realtime doc_update (primary) plus focus/route checks (fallback). + */ + watch_voucher_until_settled(doctype, docname) { + if (!doctype || !docname) { + return; + } + + if (!this._watched_vouchers) { + this._watched_vouchers = new Map(); + } + + const key = this._voucher_watch_key(doctype, docname); + if (this._watched_vouchers.has(key)) { + return; + } + + this._watched_vouchers.set(key, { + doctype, + docname, + in_flight: false, + }); + this._subscribe_voucher_doc(doctype, docname); + this.ensure_voucher_watch_listeners(); + } + + /** Subscribe to every reserved voucher currently in the transaction list. */ + sync_reserved_voucher_watches() { + const wanted = new Set(); + for (const transaction of this.transactions || []) { + if (!transaction.reserved_voucher || !transaction.reserved_voucher_type) { + continue; + } + const key = this._voucher_watch_key( + transaction.reserved_voucher_type, + transaction.reserved_voucher, + ); + wanted.add(key); + this.watch_voucher_until_settled( + transaction.reserved_voucher_type, + transaction.reserved_voucher, + ); + } + + for (const [key, state] of [...(this._watched_vouchers || [])]) { + if (!wanted.has(key)) { + this.unwatch_voucher(state.doctype, state.docname); + } + } + } + + _voucher_watch_key(doctype, docname) { + return `${doctype}::${docname}`; + } + + _subscribe_voucher_doc(doctype, docname) { + const open_key = `${doctype}:${docname}`; + if (frappe.realtime.open_docs?.has(open_key)) { + return; + } + // doc_subscribe throttles to 1/sec via frappe.flags.doc_subscribe (Frappe v15, + // socketio_client.js). Emit directly while the flag is set so batch voucher watches + // in the same tick are not dropped after the first subscription. + if (frappe.flags.doc_subscribe) { + frappe.realtime.emit("doc_subscribe", doctype, docname); + frappe.realtime.open_docs.add(open_key); + } else { + frappe.realtime.doc_subscribe(doctype, docname); + } + } + + unwatch_voucher(doctype, docname) { + const key = this._voucher_watch_key(doctype, docname); + if (!this._watched_vouchers?.has(key)) { + return; + } + this._watched_vouchers.delete(key); + frappe.realtime.doc_unsubscribe(doctype, docname); + } + + ensure_voucher_watch_listeners() { + if (this._voucher_watch_bound) { + return; + } + this._voucher_watch_bound = true; + + this._on_voucher_doc_update = (data) => { + if (!this._voucher_watch_bound) { + return; + } + this.on_voucher_doc_update(data); + }; + frappe.realtime.on("doc_update", this._on_voucher_doc_update); + + $(window).on("focus.brt_voucher_watch", () => { + if (!this._voucher_watch_bound) { + return; + } + this.run_voucher_watch_check(); + }); + // frappe.router.off cannot remove a specific handler; guard with _voucher_watch_bound + this._on_voucher_route_change = () => { + if (!this._voucher_watch_bound) { + return; + } + const route = frappe.get_route_str(); + if (route.startsWith("Form/Bank Reconciliation Tool Beta")) { + this.run_voucher_watch_check(); + } else { + this.cleanup_voucher_watches(); + } + }; + frappe.router.on("change", this._on_voucher_route_change); + } + + on_voucher_doc_update(data) { + if (!data?.doctype || !data?.name || !this._watched_vouchers?.size) { + return; + } + const key = this._voucher_watch_key(data.doctype, data.name); + if (!this._watched_vouchers.has(key)) { + return; + } + this.check_voucher_settled(data.doctype, data.name); + } + + async run_voucher_watch_check() { + if (!this._watched_vouchers?.size) { + return; + } + const watches = [...this._watched_vouchers.values()]; + for (const state of watches) { + await this.check_voucher_settled(state.doctype, state.docname); + } + } + + async check_voucher_settled(doctype, docname) { + const key = this._voucher_watch_key(doctype, docname); + const state = this._watched_vouchers?.get(key); + if (!state || state.in_flight) { + return; + } + + state.in_flight = true; + try { + const exists = await frappe.db.exists(doctype, docname); + if (!exists) { + await this.handle_voucher_settled(doctype, docname); + return; + } + + const response = await frappe.db.get_value(doctype, docname, "docstatus"); + const docstatus = cint(response?.message?.docstatus); + if (docstatus === 1 || docstatus === 2) { + await this.handle_voucher_settled(doctype, docname); + } + } finally { + const current = this._watched_vouchers?.get(key); + if (current === state) { + state.in_flight = false; + } + } + } + + async handle_voucher_settled(doctype, docname) { + this.unwatch_voucher(doctype, docname); + await this.reload_transactions_after_voucher_settled(); + } + + async reload_transactions_after_voucher_settled() { + // Deduplicate socket + focus/route fallback firing together + if (this._voucher_settled_reload) { + return this._voucher_settled_reload; + } + this._voucher_settled_reload = this.reload_transactions().finally(() => { + this._voucher_settled_reload = null; + }); + return this._voucher_settled_reload; + } + + cleanup_voucher_watches() { + for (const state of [...(this._watched_vouchers?.values() || [])]) { + frappe.realtime.doc_unsubscribe(state.doctype, state.docname); + } + this._watched_vouchers = new Map(); + + if (this._on_voucher_doc_update) { + frappe.realtime.off("doc_update", this._on_voucher_doc_update); + this._on_voucher_doc_update = null; + } + $(window).off("focus.brt_voucher_watch"); + // Route listener is guarded by _voucher_watch_bound (router.off is unreliable) + this._voucher_watch_bound = false; + this._voucher_settled_reload = null; + } + + clear_voucher_watch() { + this.cleanup_voucher_watches(); + } + render_sort_area() { this.$sort_area = this.$panel_wrapper.find(".sort-by"); this.$sort_area.append(` @@ -214,17 +426,25 @@ erpnext.accounts.bank_reconciliation.PanelManager = class PanelManager { this.transactions.map((transaction) => { let amount = transaction.deposit || transaction.withdrawal; let symbol = transaction.withdrawal ? "-" : "+"; + const draft_badge = transaction.reserved_voucher + ? `${__( + "Draft", + )}` + : ""; let $row = this.$list_container .append( ` -
+
- ${frappe.format(transaction.date, { - fieldtype: "Date", - })} + + ${frappe.format(transaction.date, { fieldtype: "Date" })} + + ${draft_badge}
diff --git a/banking/public/scss/bank_reconciliation_beta.scss b/banking/public/scss/bank_reconciliation_beta.scss index f80afa5c..c6c3c05c 100644 --- a/banking/public/scss/bank_reconciliation_beta.scss +++ b/banking/public/scss/bank_reconciliation_beta.scss @@ -46,6 +46,11 @@ * .account-holder-value { font-weight: 600; } + + .reserved-draft-badge { + margin-left: 6px; + vertical-align: middle; + } } } @@ -80,6 +85,14 @@ float: right; } + .reserved-draft-resume { + padding: var(--padding-md); + + > p { + margin-bottom: var(--margin-md); + } + } + * .dt-scrollable { height: calc(100vh - 550px) !important; }