diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index 6e5998a62a2..8e10756d97f 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -6,8 +6,10 @@ import frappe from frappe import _, msgprint, qb from frappe.model.document import Document from frappe.model.meta import get_field_precision +from frappe.permissions import get_allowed_docs_for_doctype, get_user_permissions from frappe.query_builder import Case, Criterion from frappe.query_builder.custom import ConstantColumn +from frappe.query_builder.functions import IfNull from frappe.utils import flt, fmt_money, get_link_to_form, getdate, nowdate, today import erpnext @@ -73,6 +75,7 @@ class PaymentReconciliation(Document): self.accounting_dimension_filter_conditions = [] self.ple_posting_date_filter = [] self.dimensions = get_dimensions(with_cost_center_and_project=True)[0] + self.user_permissions = get_user_permissions(frappe.session.user) def load_from_db(self): # 'modified' attribute is required for `run_doc_method` to work properly. @@ -153,6 +156,22 @@ class PaymentReconciliation(Document): self.add_payment_entries(non_reconciled_payments) + def get_permitted_dimension_values(self, document_type, reference_doctype): + return get_allowed_docs_for_doctype(self.user_permissions.get(document_type, []), reference_doctype) + + def validate_permitted_dimension_value(self, document_type, value, allowed): + if value and allowed and value not in allowed: + frappe.throw( + _("You do not have enough permission to access {0}: {1}").format(_(document_type), value), + frappe.PermissionError, + ) + + def get_user_permission_dimension_condition(self, field, allowed): + value_condition = field.isin(allowed) + if frappe.get_system_settings("apply_strict_user_permissions"): + return value_condition + return (IfNull(field, "") == "") | value_condition + def get_payment_entries(self): party_account = [self.receivable_payable_account] @@ -176,8 +195,13 @@ class PaymentReconciliation(Document): dimensions = {} for x in self.dimensions: dimension = x.fieldname - if self.get(dimension): - dimensions.update({dimension: self.get(dimension)}) + allowed = self.get_permitted_dimension_values(x.document_type, "Payment Entry") + if value := self.get(dimension): + self.validate_permitted_dimension_value(x.document_type, value, allowed) + dimensions[dimension] = value + elif allowed: + dimensions[dimension] = allowed + condition.update({"accounting_dimensions": dimensions}) payment_entries = get_advance_payment_entries_for_regional( @@ -201,8 +225,12 @@ class PaymentReconciliation(Document): # Dimension filters for x in self.dimensions: dimension = x.fieldname - if self.get(dimension): - conditions.append(jea[dimension] == self.get(dimension)) + allowed = self.get_permitted_dimension_values(x.document_type, "Journal Entry Account") + if value := self.get(dimension): + self.validate_permitted_dimension_value(x.document_type, value, allowed) + conditions.append(jea[dimension] == value) + elif allowed: + conditions.append(self.get_user_permission_dimension_condition(jea[dimension], allowed)) if self.payment_name: conditions.append(je.name.like(f"%%{self.payment_name}%%")) @@ -748,8 +776,15 @@ class PaymentReconciliation(Document): ple = qb.DocType("Payment Ledger Entry") for x in self.dimensions: dimension = x.fieldname - if self.get(dimension) and frappe.db.has_column("Payment Ledger Entry", dimension): - self.accounting_dimension_filter_conditions.append(ple[dimension] == self.get(dimension)) + if frappe.db.has_column("Payment Ledger Entry", dimension): + allowed = self.get_permitted_dimension_values(x.document_type, "Payment Ledger Entry") + if value := self.get(dimension): + self.validate_permitted_dimension_value(x.document_type, value, allowed) + self.accounting_dimension_filter_conditions.append(ple[dimension] == value) + elif allowed: + self.accounting_dimension_filter_conditions.append( + self.get_user_permission_dimension_condition(ple[dimension], allowed) + ) def build_qb_filter_conditions(self, get_invoices=False, get_return_invoices=False): self.common_filter_conditions.clear() diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py index b6ac01e3074..a7fd21ee667 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py @@ -3,7 +3,7 @@ import frappe -from frappe.utils import add_days, add_years, flt, getdate, nowdate, today +from frappe.utils import add_days, add_years, cint, flt, getdate, nowdate, today from frappe.utils.data import getdate as convert_to_date from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry @@ -1102,6 +1102,101 @@ class TestPaymentReconciliation(ERPNextTestSuite): payment_vouchers = [x.get("reference_name") for x in pr.get("payments")] self.assertCountEqual(payment_vouchers, [je2.name, pe2.name]) + def test_user_permission_on_accounting_dimension_filters_vouchers(self): + test_user = "test@example.com" + permitted_ccs = ["_Test Cost Center - _TC", "_Test Cost Center 2 - _TC"] + restricted_cc = "_Test Write Off Cost Center - _TC" + existing_apply_strict_user_permissions = cint( + frappe.db.get_single_value("System Settings", "apply_strict_user_permissions") + ) + self.addCleanup( + frappe.db.set_single_value, + "System Settings", + "apply_strict_user_permissions", + existing_apply_strict_user_permissions, + ) + transaction_date = nowdate() + rate = 100 + + def make_invoice(cost_center): + si = self.create_sales_invoice( + qty=1, rate=rate, posting_date=transaction_date, do_not_submit=True + ) + si.cost_center = cost_center + for row in si.items: + row.cost_center = cost_center + return si.submit() + + def make_payment(cost_center): + pe = self.create_payment_entry(posting_date=transaction_date, amount=rate) + pe.cost_center = cost_center + return pe.save().submit() + + def make_journal(cost_center): + je = self.create_journal_entry( + self.bank, self.debit_to, 100, transaction_date, cost_center=cost_center + ) + je.accounts[1].party_type = "Customer" + je.accounts[1].party = self.customer + return je.save().submit() + + # Vouchers tagged with the two permitted cost centers + si_allowed = make_invoice(permitted_ccs[0]) + pe_allowed = make_payment(permitted_ccs[1]) + je_allowed = make_journal(permitted_ccs[0]) + + # Vouchers tagged with the restricted cost center + si_restricted = make_invoice(restricted_cc) + pe_restricted = make_payment(restricted_cc) + je_restricted = make_journal(restricted_cc) + + # Payment entry with a BLANK cost center + pe_blank = make_payment(None) + + for cc in permitted_ccs: + frappe.permissions.add_user_permission("Cost Center", cc, test_user) + + # Without strict user permissions + frappe.db.set_single_value("System Settings", "apply_strict_user_permissions", 0) + with self.set_user(test_user): + pr = self.create_payment_reconciliation() + pr.get_unreconciled_entries() + + invoice_numbers = [x.get("invoice_number") for x in pr.get("invoices")] + payment_vouchers = [x.get("reference_name") for x in pr.get("payments")] + self.assertIn(si_allowed.name, invoice_numbers) + self.assertIn(pe_allowed.name, payment_vouchers) + self.assertIn(je_allowed.name, payment_vouchers) + self.assertIn(pe_blank.name, payment_vouchers) + self.assertNotIn(si_restricted.name, invoice_numbers) + self.assertNotIn(pe_restricted.name, payment_vouchers) + self.assertNotIn(je_restricted.name, payment_vouchers) + + # With strict user permissions + frappe.db.set_single_value("System Settings", "apply_strict_user_permissions", 1) + with self.set_user(test_user): + pr = self.create_payment_reconciliation() + pr.get_unreconciled_entries() + + invoice_numbers = [x.get("invoice_number") for x in pr.get("invoices")] + payment_vouchers = [x.get("reference_name") for x in pr.get("payments")] + self.assertIn(si_allowed.name, invoice_numbers) + self.assertIn(pe_allowed.name, payment_vouchers) + self.assertIn(je_allowed.name, payment_vouchers) + self.assertNotIn(pe_blank.name, payment_vouchers) + self.assertNotIn(si_restricted.name, invoice_numbers) + self.assertNotIn(pe_restricted.name, payment_vouchers) + self.assertNotIn(je_restricted.name, payment_vouchers) + + # with restricted dimension as a filter + with self.set_user(test_user): + pr = self.create_payment_reconciliation() + pr.cost_center = restricted_cc + self.assertRaises(frappe.PermissionError, pr.get_unreconciled_entries) + + for cc in permitted_ccs: + frappe.permissions.remove_user_permission("Cost Center", cc, test_user) + @ERPNextTestSuite.change_settings( "Accounts Settings", { diff --git a/erpnext/accounts/services/advances.py b/erpnext/accounts/services/advances.py index 5ac759cf1fe..2245f1d8c41 100644 --- a/erpnext/accounts/services/advances.py +++ b/erpnext/accounts/services/advances.py @@ -12,7 +12,7 @@ import frappe from frappe import _ from frappe.query_builder import Criterion from frappe.query_builder.custom import ConstantColumn -from frappe.query_builder.functions import Abs, Max, Sum +from frappe.query_builder.functions import Abs, IfNull, Max, Sum from frappe.utils import flt import erpnext @@ -481,8 +481,18 @@ def get_common_query( if condition.get("cost_center"): common_filter_conditions.append(payment_entry.cost_center == condition["cost_center"]) if condition.get("accounting_dimensions"): + apply_strict_user_permissions = frappe.get_system_settings("apply_strict_user_permissions") for field, val in condition.get("accounting_dimensions").items(): - common_filter_conditions.append(payment_entry[field] == val) + if isinstance(val, list | tuple | set): + value_condition = payment_entry[field].isin(val) + if apply_strict_user_permissions: + common_filter_conditions.append(value_condition) + else: + common_filter_conditions.append( + (IfNull(payment_entry[field], "") == "") | value_condition + ) + else: + common_filter_conditions.append(payment_entry[field] == val) if condition.get("minimum_payment_amount"): common_filter_conditions.append( payment_entry.unallocated_amount.gte(condition["minimum_payment_amount"])