diff --git a/banking/custom_fields.py b/banking/custom_fields.py index 93fb10f7..21ae9ef9 100644 --- a/banking/custom_fields.py +++ b/banking/custom_fields.py @@ -108,6 +108,14 @@ def get_custom_fields(): allow_on_submit=1, no_copy=1, ), + dict( + fieldname="on_hold_until", + label=_("On Hold Until"), + fieldtype="Date", + insert_after="reserved_voucher", + allow_on_submit=1, + no_copy=1, + ), ], "Journal Entry": [ dict( 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 7b0f8819..074dbf4c 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 @@ -20,7 +20,7 @@ from frappe.model.document import Document from frappe.query_builder.custom import ConstantColumn from frappe.query_builder.functions import Cast, Coalesce, Sum -from frappe.utils import flt, sbool +from frappe.utils import add_days, cint, flt, nowdate, sbool from pypika import Order from pypika.terms import ExistsCriterion @@ -119,6 +119,10 @@ def get_bank_transactions( ["docstatus", "=", 1], ["status", "not in", ["Reconciled", "Cancelled"]], ] + or_filters = [ + ["on_hold_until", "is", "not set"], + ["on_hold_until", "<", nowdate()], + ] if bank_account: filters.append(["bank_account", "=", bank_account]) @@ -164,6 +168,7 @@ def get_bank_transactions( "Bank Transaction", fields=fields, filters=filters, + or_filters=or_filters, order_by=order_by, ) enrich_transactions_with_deposit_fee_for_reconciliation(transactions) @@ -171,6 +176,53 @@ def get_bank_transactions( return transactions +@frappe.whitelist() +def set_bank_transaction_on_hold(bank_transaction_name: str, on_hold_until: str) -> None: + """Hide a submitted Bank Transaction from reconciliation until the given date.""" + transaction = frappe.get_doc("Bank Transaction", bank_transaction_name) + frappe.has_permission("Bank Transaction", "write", transaction, throw=True) + transaction.db_set("on_hold_until", on_hold_until, update_modified=True) + + +@frappe.whitelist() +def request_invoice(bank_transaction_name: str, recipient_type: str, recipient: str) -> None: + """Request an invoice and optionally put its Bank Transaction on hold.""" + transaction = frappe.get_doc("Bank Transaction", bank_transaction_name) + frappe.has_permission("Bank Transaction", "write", transaction, throw=True) + + recipient_email = get_invoice_request_recipient_email(recipient_type, recipient) + if not recipient_email: + frappe.throw(_("The selected {0} has no email address.").format(_(recipient_type))) + + frappe.sendmail( + recipients=[recipient_email], + subject=_("Invoice request for Bank Transaction {0}").format(transaction.name), + message=_("Please provide the invoice for Bank Transaction {0}.").format(transaction.name), + reference_doctype=transaction.doctype, + reference_name=transaction.name, + ) + + if frappe.db.get_single_value("Banking Settings", "automatically_set_on_hold_after_invoice_request"): + threshold = cint( + frappe.db.get_single_value("Banking Settings", "on_hold_threshold_after_invoice_request") + ) + transaction.db_set("on_hold_until", add_days(nowdate(), threshold), update_modified=True) + + +def get_invoice_request_recipient_email(recipient_type: str, recipient: str) -> str | None: + """Return the primary email address for a supported invoice-request recipient.""" + field_map = { + "User": ("User", "email"), + "Contact": ("Contact", "email_id"), + "Employee": ("Employee", "company_email"), + } + if recipient_type not in field_map: + frappe.throw(_("Recipient type must be User, Contact, or Employee.")) + + doctype, fieldname = field_map[recipient_type] + return frappe.db.get_value(doctype, recipient, fieldname) + + def enrich_transactions_with_deposit_fee_for_reconciliation(transactions: list) -> None: """Expose deposit included fees that unpaid-invoice reconciliation can book. 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 98e22e83..bbd811b2 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 @@ -29,6 +29,8 @@ create_payment_entry_bts, get_bank_transactions, get_linked_payments, + request_invoice, + set_bank_transaction_on_hold, ) test_dependencies = ["Warehouse", "Item", "Account", "Cost Center", "UOM", "Company"] @@ -1688,6 +1690,34 @@ def test_get_bank_transactions_hides_fee_without_bank_fee_account(self): row = next(transaction for transaction in transactions if transaction.name == bt.name) self.assertEqual(row.included_fee_for_reconciliation, 0) + def test_on_hold_transaction_is_hidden_until_the_following_day(self): + bt = create_bank_transaction(deposit=100, bank_account=self.bank_account) + + set_bank_transaction_on_hold(bt.name, getdate()) + + transactions = get_bank_transactions(bank_account=self.bank_account) + self.assertNotIn(bt.name, [transaction.name for transaction in transactions]) + + frappe.db.set_value("Bank Transaction", bt.name, "on_hold_until", add_days(getdate(), -1)) + transactions = get_bank_transactions(bank_account=self.bank_account) + self.assertIn(bt.name, [transaction.name for transaction in transactions]) + + def test_request_invoice_sends_email_and_sets_on_hold(self): + bt = create_bank_transaction(deposit=100, bank_account=self.bank_account) + frappe.db.set_single_value("Banking Settings", "automatically_set_on_hold_after_invoice_request", 1) + frappe.db.set_single_value("Banking Settings", "on_hold_threshold_after_invoice_request", 3) + + with patch( + "banking.klarna_kosma_integration.doctype.bank_reconciliation_tool_beta.bank_reconciliation_tool_beta.frappe.sendmail" + ) as sendmail: + request_invoice(bt.name, "User", "Administrator") + + sendmail.assert_called_once() + self.assertEqual(sendmail.call_args.kwargs["recipients"], ["Administrator"]) + self.assertEqual( + frappe.db.get_value("Bank Transaction", bt.name, "on_hold_until"), add_days(getdate(), 3) + ) + def _create_draft_journal_entry(self, bt, **kwargs): defaults = { "bank_transaction_name": bt.name, diff --git a/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.json b/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.json index fe1bc9a3..04dbe805 100644 --- a/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.json +++ b/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.json @@ -20,6 +20,9 @@ "fintech_license_key", "bank_reconciliation_tab", "enable_automatic_journal_entries_for_bank_fees", + "invoice_request_section", + "automatically_set_on_hold_after_invoice_request", + "on_hold_threshold_after_invoice_request", "advanced_section", "reference_fields", "voucher_matching_defaults" @@ -123,6 +126,25 @@ "fieldname": "enable_automatic_journal_entries_for_bank_fees", "fieldtype": "Check", "label": "Enable Automatic Journal Entries for Bank Fees" + }, + { + "fieldname": "invoice_request_section", + "fieldtype": "Section Break", + "label": "Invoice Requests" + }, + { + "default": "0", + "fieldname": "automatically_set_on_hold_after_invoice_request", + "fieldtype": "Check", + "label": "Automatically Set On Hold after Invoice Request" + }, + { + "default": "7", + "depends_on": "automatically_set_on_hold_after_invoice_request", + "fieldname": "on_hold_threshold_after_invoice_request", + "fieldtype": "Int", + "label": "On Hold Threshold (after Invoice Request)", + "non_negative": 1 } ], "grid_page_length": 50, 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 f32822d4..c42d0e1e 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 @@ -83,6 +83,17 @@ banking.bank_reconciliation.ActionsPanelManager = class ActionsPanelManager { }); }, }, + { + tab_name: "on_hold", + tab_label: __("On Hold"), + make_tab: () => { + return new banking.bank_reconciliation.OnHoldTab({ + actions_panel: this, + transaction: this.transaction, + panel_manager: this.panel_manager, + }); + }, + }, ]; if (!is_reserved) { @@ -139,10 +150,10 @@ banking.bank_reconciliation.ActionsPanelManager = class ActionsPanelManager { message: with_new_voucher ? __("Bank Transaction {0} partially reconciled.", [ this.transaction.name, - ]) + ]) : __("Bank Transaction {0} partially matched.", [ this.transaction.name, - ]), + ]), indicator: "blue", }); this.panel_manager.refresh_transaction(unallocated_amount); diff --git a/banking/public/js/bank_reconciliation_beta/actions_panel/on_hold_tab.js b/banking/public/js/bank_reconciliation_beta/actions_panel/on_hold_tab.js new file mode 100644 index 00000000..252dd1ef --- /dev/null +++ b/banking/public/js/bank_reconciliation_beta/actions_panel/on_hold_tab.js @@ -0,0 +1,87 @@ +frappe.provide("banking.bank_reconciliation"); + +banking.bank_reconciliation.OnHoldTab = class OnHoldTab { + constructor(opts) { + $.extend(this, opts); + this.make(); + } + + make() { + this.panel_manager.actions_tab = "on_hold-tab"; + this.field_group = new frappe.ui.FieldGroup({ + fields: this.get_fields(), + body: this.actions_panel.$tab_content, + card_layout: true, + doc: this.transaction, + }); + this.field_group.make(); + } + + get_fields() { + return [ + { + label: __("On Hold Until"), + fieldname: "on_hold_until", + fieldtype: "Date", + reqd: 1, + }, + { + label: __("Set On Hold"), + fieldname: "set_on_hold", + fieldtype: "Button", + primary: true, + click: () => this.set_on_hold(), + }, + { fieldtype: "Section Break", label: __("Request Invoice") }, + { + label: __("Recipient Type"), + fieldname: "recipient_type", + fieldtype: "Select", + options: "User\nContact\nEmployee", + default: "Employee", + reqd: 1, + }, + { + label: __("Recipient"), + fieldname: "recipient", + fieldtype: "Dynamic Link", + options: "recipient_type", + reqd: 1, + }, + { + label: __("Request Invoice"), + fieldname: "request_invoice", + fieldtype: "Button", + click: () => this.request_invoice(), + }, + ]; + } + + set_on_hold() { + const on_hold_until = this.field_group.get_value("on_hold_until"); + if (on_hold_until) { + this.call("set_bank_transaction_on_hold", { on_hold_until }); + } + } + + request_invoice() { + const recipient_type = this.field_group.get_value("recipient_type"); + const recipient = this.field_group.get_value("recipient"); + if (recipient_type && recipient) { + this.call("request_invoice", { recipient_type, recipient }); + } + } + + call(method, args) { + frappe.call({ + method: `banking.klarna_kosma_integration.doctype.bank_reconciliation_tool_beta.bank_reconciliation_tool_beta.${method}`, + args: { bank_transaction_name: this.transaction.name, ...args }, + freeze: true, + callback: (response) => { + if (!response.exc) { + this.panel_manager.reload_transactions(); + } + }, + }); + } +}; diff --git a/banking/public/js/bank_reconciliation_beta/bank_reconciliation_beta.bundle.js b/banking/public/js/bank_reconciliation_beta/bank_reconciliation_beta.bundle.js index cb5c01c3..b255705e 100644 --- a/banking/public/js/bank_reconciliation_beta/bank_reconciliation_beta.bundle.js +++ b/banking/public/js/bank_reconciliation_beta/bank_reconciliation_beta.bundle.js @@ -4,6 +4,7 @@ import "./actions_panel/actions_panel_manager"; import "./actions_panel/create_tab"; import "./actions_panel/details_tab"; import "./actions_panel/match_tab"; +import "./actions_panel/on_hold_tab"; import "./actions_panel/reconcile_conversion_dialog"; import "./summary_number_card";