diff --git a/.github/helper/install.sh b/.github/helper/install.sh index 10c05e5..0c67aa2 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -49,6 +49,8 @@ bench get-app --overwrite csf_za "${GITHUB_WORKSPACE}" bench --verbose setup env --python python3.10 bench --verbose setup requirements --dev +~/frappe-bench/env/bin/python -m pip install --force-reinstall "setuptools==81.0.0" + bench start &>> ~/frappe-bench/bench_start.log & CI=Yes bench build --app frappe & bench --site test_site reinstall --yes diff --git a/csf_za/__init__.py b/csf_za/__init__.py index 6cd38b7..493f741 100644 --- a/csf_za/__init__.py +++ b/csf_za/__init__.py @@ -1 +1 @@ -__version__ = "0.2.7" +__version__ = "0.3.0" diff --git a/csf_za/tax_compliance/doctype/value_added_tax_return/test_value_added_tax_return.py b/csf_za/tax_compliance/doctype/value_added_tax_return/test_value_added_tax_return.py index c622b65..b2e132c 100644 --- a/csf_za/tax_compliance/doctype/value_added_tax_return/test_value_added_tax_return.py +++ b/csf_za/tax_compliance/doctype/value_added_tax_return/test_value_added_tax_return.py @@ -484,3 +484,461 @@ def test_journal_entry_write_off_classification(self): ) self.assertEqual(write_off_entry.tax_amount, 15) self.assertEqual(write_off_entry.incl_tax_amount, 115) + + def test_journal_entry_multiple_vat_lines(self): + expense_account_1 = create_account( + "Expense 1", "Direct Expenses - _TC", self.company, account_type="Expense Account" + ) + expense_account_1.custom_vat_return_debit_classification = ( + "Input - C Other goods supplied to you (excl capital goods)" + ) + expense_account_1.save() + + expense_account_2 = create_account( + "Expense 2", "Direct Expenses - _TC", self.company, account_type="Expense Account" + ) + expense_account_2.custom_vat_return_debit_classification = ( + "Input - A Capital goods and/or services supplied to you (local)" + ) + expense_account_2.save() + + bank_account = create_account("Bank Test", "Current Assets - _TC", self.company, account_type="Bank") + + je = frappe.get_doc( + { + "doctype": "Journal Entry", + "voucher_type": "Journal Entry", + "company": self.company, + "posting_date": "2025-08-21", + "accounts": [ + {"account": expense_account_1.name, "debit_in_account_currency": 200}, + {"account": self.vat_account.name, "debit_in_account_currency": 30}, + {"account": expense_account_2.name, "debit_in_account_currency": 100}, + {"account": self.vat_account.name, "debit_in_account_currency": 15}, + {"account": bank_account.name, "credit_in_account_currency": 345}, + ], + } + ) + je.insert() + je.submit() + + vat_return = frappe.get_doc( + { + "doctype": "Value-added Tax Return", + "company": self.company, + "date_from": "2025-08-01", + "date_to": "2025-08-31", + } + ) + vat_return.insert() + + gl_entries_data = vat_return.get_gl_entries() + vat_return.gl_entries = [] + for gle in gl_entries_data: + vat_return.append("gl_entries", gle) + vat_return.save() + + self.assertEqual(len(vat_return.gl_entries), 2) + total_input_tax = sum(d.tax_amount for d in vat_return.gl_entries) + self.assertEqual(total_input_tax, 45) + + def test_journal_entry_multiple_vat_lines_summary_totals(self): + expense_account_1 = create_account( + "Expense 1", "Direct Expenses - _TC", self.company, account_type="Expense Account" + ) + expense_account_1.custom_vat_return_debit_classification = ( + "Input - C Other goods supplied to you (excl capital goods)" + ) + expense_account_1.save() + + expense_account_2 = create_account( + "Expense 2", "Direct Expenses - _TC", self.company, account_type="Expense Account" + ) + expense_account_2.custom_vat_return_debit_classification = ( + "Input - A Capital goods and/or services supplied to you (local)" + ) + expense_account_2.save() + + bank_account = create_account("Bank Test", "Current Assets - _TC", self.company, account_type="Bank") + + je = frappe.get_doc( + { + "doctype": "Journal Entry", + "voucher_type": "Journal Entry", + "company": self.company, + "posting_date": "2025-08-21", + "accounts": [ + {"account": expense_account_1.name, "debit_in_account_currency": 200}, + {"account": self.vat_account.name, "debit_in_account_currency": 30}, + {"account": expense_account_2.name, "debit_in_account_currency": 100}, + {"account": self.vat_account.name, "debit_in_account_currency": 15}, + {"account": bank_account.name, "credit_in_account_currency": 345}, + ], + } + ) + je.insert() + je.submit() + + vat_return = frappe.get_doc( + { + "doctype": "Value-added Tax Return", + "company": self.company, + "date_from": "2025-08-01", + "date_to": "2025-08-31", + } + ) + vat_return.insert() + + gl_entries_data = vat_return.get_gl_entries() + vat_return.gl_entries = [] + for gle in gl_entries_data: + vat_return.append("gl_entries", gle) + vat_return.save() + + self.assertEqual(len(vat_return.gl_entries), 2) + self.assertEqual(sum(d.tax_amount for d in vat_return.gl_entries), 45) + self.assertEqual(vat_return.total_input_tax, 45) + + def test_journal_entry_multiple_vat_legs_classification(self): + expense_account_1 = create_account( + "Expense 1", "Direct Expenses - _TC", self.company, account_type="Expense Account" + ) + expense_account_1.custom_vat_return_debit_classification = ( + "Input - C Other goods supplied to you (excl capital goods)" + ) + expense_account_1.save() + + expense_account_2 = create_account( + "Expense 2", "Direct Expenses - _TC", self.company, account_type="Expense Account" + ) + expense_account_2.custom_vat_return_debit_classification = ( + "Input - A Capital goods and/or services supplied to you (local)" + ) + expense_account_2.save() + + bank_account = create_account("Bank Test", "Current Assets - _TC", self.company, account_type="Bank") + + je = frappe.get_doc( + { + "doctype": "Journal Entry", + "voucher_type": "Journal Entry", + "company": self.company, + "posting_date": "2025-08-21", + "accounts": [ + {"account": expense_account_1.name, "debit_in_account_currency": 200}, + {"account": self.vat_account.name, "debit_in_account_currency": 30}, + {"account": expense_account_2.name, "debit_in_account_currency": 100}, + {"account": self.vat_account.name, "debit_in_account_currency": 15}, + {"account": bank_account.name, "credit_in_account_currency": 345}, + ], + } + ) + je.insert() + je.submit() + + vat_return = frappe.get_doc( + { + "doctype": "Value-added Tax Return", + "company": self.company, + "date_from": "2025-08-01", + "date_to": "2025-08-31", + } + ) + vat_return.insert() + + gl_entries_data = vat_return.get_gl_entries() + vat_return.gl_entries = [] + for gle in gl_entries_data: + vat_return.append("gl_entries", gle) + vat_return.save() + + self.assertEqual(len(vat_return.gl_entries), 2) + + rows_by_tax = {row.tax_amount: row for row in vat_return.gl_entries} + + row_30 = rows_by_tax[30] + self.assertEqual(row_30.classification, "Input - C Other goods supplied to you (excl capital goods)") + self.assertEqual(row_30.incl_tax_amount, 230) + + row_15 = rows_by_tax[15] + self.assertEqual( + row_15.classification, "Input - A Capital goods and/or services supplied to you (local)" + ) + self.assertEqual(row_15.incl_tax_amount, 115) + + self.assertEqual(vat_return.total_input_tax, 45) + + def test_journal_entry_exempt_no_vat_leg(self): + interest_account = create_account("Interest Received Test", "Indirect Income - _TC", self.company) + interest_account.custom_vat_return_credit_classification = "Output - E Exempt" + interest_account.save() + + bank_account = create_account( + "Bank Exempt Test", "Current Assets - _TC", self.company, account_type="Bank" + ) + + je = frappe.get_doc( + { + "doctype": "Journal Entry", + "voucher_type": "Journal Entry", + "company": self.company, + "posting_date": "2025-08-15", + "accounts": [ + {"account": bank_account.name, "debit_in_account_currency": 1000}, + {"account": interest_account.name, "credit_in_account_currency": 1000}, + ], + } + ) + je.insert() + je.submit() + + vat_return = frappe.get_doc( + { + "doctype": "Value-added Tax Return", + "company": self.company, + "date_from": "2025-08-01", + "date_to": "2025-08-31", + } + ) + vat_return.insert() + + gl_entries_data = vat_return.get_gl_entries() + vat_return.gl_entries = [] + for gle in gl_entries_data: + vat_return.append("gl_entries", gle) + vat_return.save() + + self.assertEqual(len(vat_return.gl_entries), 1) + + row = vat_return.gl_entries[0] + self.assertEqual(row.classification, "Output - E Exempt") + self.assertEqual(row.tax_amount, 0) + self.assertEqual(row.incl_tax_amount, 1000) + self.assertEqual(vat_return.exempt_excl, 1000) + + def test_journal_entry_exempt_with_vat_leg_not_duplicated(self): + exempt_account = create_account("Exempt Income Test", "Indirect Income - _TC", self.company) + exempt_account.custom_vat_return_credit_classification = "Output - E Exempt" + exempt_account.save() + + bank_account = create_account( + "Bank Exempt2 Test", "Current Assets - _TC", self.company, account_type="Bank" + ) + + je = frappe.get_doc( + { + "doctype": "Journal Entry", + "voucher_type": "Journal Entry", + "company": self.company, + "posting_date": "2025-08-15", + "accounts": [ + {"account": bank_account.name, "debit_in_account_currency": 115}, + {"account": self.vat_account.name, "credit_in_account_currency": 15}, + {"account": exempt_account.name, "credit_in_account_currency": 100}, + ], + } + ) + je.insert() + je.submit() + + vat_return = frappe.get_doc( + { + "doctype": "Value-added Tax Return", + "company": self.company, + "date_from": "2025-08-01", + "date_to": "2025-08-31", + } + ) + vat_return.insert() + + gl_entries_data = vat_return.get_gl_entries() + vat_return.gl_entries = [] + for gle in gl_entries_data: + vat_return.append("gl_entries", gle) + vat_return.save() + + self.assertEqual(len(vat_return.gl_entries), 1) + self.assertEqual(vat_return.gl_entries[0].tax_amount, 15) + + def test_journal_entry_multiple_vat_legs_same_amount(self): + expense_account = create_account( + "Expense Same", "Direct Expenses - _TC", self.company, account_type="Expense Account" + ) + expense_account.custom_vat_return_debit_classification = ( + "Input - C Other goods supplied to you (excl capital goods)" + ) + expense_account.save() + + bank_account = create_account("Bank Same", "Current Assets - _TC", self.company, account_type="Bank") + + je = frappe.get_doc( + { + "doctype": "Journal Entry", + "voucher_type": "Journal Entry", + "company": self.company, + "posting_date": "2025-08-21", + "accounts": [ + {"account": expense_account.name, "debit_in_account_currency": 100}, + {"account": self.vat_account.name, "debit_in_account_currency": 15}, + {"account": expense_account.name, "debit_in_account_currency": 100}, + {"account": self.vat_account.name, "debit_in_account_currency": 15}, + {"account": bank_account.name, "credit_in_account_currency": 230}, + ], + } + ) + je.insert() + je.submit() + + vat_return = frappe.get_doc( + { + "doctype": "Value-added Tax Return", + "company": self.company, + "date_from": "2025-08-01", + "date_to": "2025-08-31", + } + ) + vat_return.insert() + + gl_entries_data = vat_return.get_gl_entries() + vat_return.gl_entries = [] + for gle in gl_entries_data: + vat_return.append("gl_entries", gle) + vat_return.save() + + self.assertEqual(len(vat_return.gl_entries), 2) + self.assertEqual(sum(d.tax_amount for d in vat_return.gl_entries), 30) + + def test_expense_claim_classification(self): + """ + Expense Claim with one standard-rate tax row should be fetched, have + incl_tax_amount set, and be auto-classified via the expense type's + default account. + """ + VAT_ACCOUNT = "VAT Control Account" + EXPENSE_ACCOUNT = "Telephone and Fax" + + mock_settings = frappe._dict({"tax_accounts": [frappe._dict({"account": VAT_ACCOUNT})]}) + mock_vouchers = frappe._dict( + { + "HR-EXP-2026-00001": frappe._dict( + { + "voucher": frappe._dict( + { + "voucher_type": "Expense Claim", + "voucher_no": "HR-EXP-2026-00001", + "account": VAT_ACCOUNT, + "general_ledger_debit": 1.5, + "general_ledger_credit": 0, + "expense_claim_taxes_tax_amount": 1.5, + "expense_claim_taxes_total": 11.5, + "expense_claim_grand_total": 11.5, + "taxes_and_charges_template": None, + "is_cancelled": 0, + } + ), + "linked_journal_entries": [], + } + ) + } + ) + + vat_return = frappe.new_doc("Value-added Tax Return") + vat_return.company = self.company + + with ( + patch( + "csf_za.tax_compliance.doctype.value_added_tax_return.value_added_tax_return.transform_gl_entries", + return_value=mock_vouchers, + ), + patch( + "csf_za.tax_compliance.doctype.value_added_tax_return.value_added_tax_return.frappe.get_cached_doc", + return_value=mock_settings, + ), + patch( + "csf_za.tax_compliance.doctype.value_added_tax_return.value_added_tax_return.frappe.get_all", + return_value=["Calls"], + ), + patch( + "csf_za.tax_compliance.doctype.value_added_tax_return.value_added_tax_return.frappe.db.get_value", + return_value=EXPENSE_ACCOUNT, + ), + patch( + "csf_za.tax_compliance.doctype.value_added_tax_return.value_added_tax_return.frappe.get_cached_value", + return_value="Input - C Other goods supplied to you (excl capital goods)", + ), + ): + results = vat_return.process_gl_entries([]) + + self.assertEqual(len(results), 1) + result = results[0] + self.assertEqual(result.voucher_type, "Expense Claim") + self.assertEqual(result.tax_amount, 1.5) + self.assertEqual(result.incl_tax_amount, 11.5) + self.assertEqual( + result.classification, + "Input - C Other goods supplied to you (excl capital goods)", + ) + + def test_expense_claim_unclassified_when_no_account_configured(self): + """ + Expense Claim whose expense type has no default_account for this company + should have incl_tax_amount set but remain unclassified. + """ + VAT_ACCOUNT = "VAT Control Account" + + mock_settings = frappe._dict({"tax_accounts": [frappe._dict({"account": VAT_ACCOUNT})]}) + mock_vouchers = frappe._dict( + { + "HR-EXP-2026-00002": frappe._dict( + { + "voucher": frappe._dict( + { + "voucher_type": "Expense Claim", + "voucher_no": "HR-EXP-2026-00002", + "account": VAT_ACCOUNT, + "general_ledger_debit": 3.0, + "general_ledger_credit": 0, + "expense_claim_taxes_tax_amount": 3.0, + "expense_claim_taxes_total": 23.0, + "expense_claim_grand_total": 23.0, + "taxes_and_charges_template": None, + "is_cancelled": 0, + } + ), + "linked_journal_entries": [], + } + ) + } + ) + + vat_return = frappe.new_doc("Value-added Tax Return") + vat_return.company = self.company + + with ( + patch( + "csf_za.tax_compliance.doctype.value_added_tax_return.value_added_tax_return.transform_gl_entries", + return_value=mock_vouchers, + ), + patch( + "csf_za.tax_compliance.doctype.value_added_tax_return.value_added_tax_return.frappe.get_cached_doc", + return_value=mock_settings, + ), + patch( + "csf_za.tax_compliance.doctype.value_added_tax_return.value_added_tax_return.frappe.get_all", + return_value=["Calls"], + ), + patch( + "csf_za.tax_compliance.doctype.value_added_tax_return.value_added_tax_return.frappe.db.get_value", + return_value=None, + ), + patch( + "csf_za.tax_compliance.doctype.value_added_tax_return.value_added_tax_return.frappe.get_cached_value", + return_value=None, + ), + ): + results = vat_return.process_gl_entries([]) + + self.assertEqual(len(results), 1) + result = results[0] + self.assertEqual(result.incl_tax_amount, 23.0) + self.assertIsNone(result.classification) diff --git a/csf_za/tax_compliance/doctype/value_added_tax_return/value_added_tax_return.py b/csf_za/tax_compliance/doctype/value_added_tax_return/value_added_tax_return.py index 40f8857..dee3e03 100644 --- a/csf_za/tax_compliance/doctype/value_added_tax_return/value_added_tax_return.py +++ b/csf_za/tax_compliance/doctype/value_added_tax_return/value_added_tax_return.py @@ -178,7 +178,6 @@ def get_gl_entries(self): """ vat_return_settings = frappe.get_cached_doc("Value-added Tax Return Settings", self.company) - # Construct the query using Frappe query builder gle = frappe.qb.DocType("GL Entry") je = frappe.qb.DocType("Journal Entry") jea = frappe.qb.DocType("Journal Entry Account") @@ -186,9 +185,42 @@ def get_gl_entries(self): sitc = frappe.qb.DocType("Sales Taxes and Charges") pi = frappe.qb.DocType("Purchase Invoice") pitc = frappe.qb.DocType("Purchase Taxes and Charges") + expense_claims_available = frappe.db.table_exists("Expense Claim") + if expense_claims_available: + ec = frappe.qb.DocType("Expense Claim") + ectc = frappe.qb.DocType("Expense Taxes and Charges") tax_accounts = [row.account for row in vat_return_settings.tax_accounts] + classified_accounts = frappe.get_all( + "Account", + filters={"company": self.company}, + or_filters=[ + ["custom_vat_return_debit_classification", "not in", ["", None]], + ["custom_vat_return_credit_classification", "not in", ["", None]], + ], + pluck="name", + ) + + account_condition = gle.account.isin(tax_accounts) | ( + (gle.voucher_type == "Sales Invoice") + & si.taxes_and_charges.isnotnull() + & si.debit_to.isnotnull() + & (gle.account == si.debit_to) + # Exclude Purchase Invoices with 0 tax for now + # | ( + # (gle.voucher_type == "Purchase Invoice") + # & pi.taxes_and_charges.isnotnull() + # & pi.credit_to.isnotnull() + # & (gle.account == pi.credit_to) + # ) + ) + + if classified_accounts: + account_condition = account_condition | ( + (gle.voucher_type == "Journal Entry") & gle.account.isin(classified_accounts) + ) + query = ( frappe.qb.from_(gle) .left_join(je) @@ -230,33 +262,28 @@ def get_gl_entries(self): .as_("taxes_and_charges_template"), ) .where( - (gle.posting_date >= self.date_from) - & (gle.posting_date <= self.date_to) - & ( - gle.account.isin(tax_accounts) - | ( - (gle.voucher_type == "Sales Invoice") - & si.taxes_and_charges.isnotnull() - & si.debit_to.isnotnull() - & (gle.account == si.debit_to) - ) - # Exclude Purchase Invoices with 0 tax for now - # | ( - # (gle.voucher_type == "Purchase Invoice") - # & pi.taxes_and_charges.isnotnull() - # & pi.credit_to.isnotnull() - # & (gle.account == pi.credit_to) - # ) - ) + (gle.posting_date >= self.date_from) & (gle.posting_date <= self.date_to) & account_condition ) ) - # Execute the query and fetch the result as a list of dictionaries + if expense_claims_available: + query = ( + query.left_join(ec) + .on((gle.voucher_type == "Expense Claim") & (ec.name == gle.voucher_no)) + .left_join(ectc) + .on((ectc.parent == ec.name) & (ectc.account_head == gle.account)) + .select( + ectc.tax_amount.as_("expense_claim_taxes_tax_amount"), + ectc.total.as_("expense_claim_taxes_total"), + ec.grand_total.as_("expense_claim_grand_total"), + ) + ) + result = query.run(as_dict=True) - return self.process_gl_entries(result) + return self.process_gl_entries(result, classified_accounts=classified_accounts) - def process_gl_entries(self, gl_entries): + def process_gl_entries(self, gl_entries, classified_accounts=None): """ Perform classification for each journal entry: - If it's linked to a Sales Invoice or Purchase Invoice, get the Taxes and Charges Template @@ -266,13 +293,22 @@ def process_gl_entries(self, gl_entries): vat_return_settings = frappe.get_cached_doc("Value-added Tax Return Settings", self.company) tax_accounts = [row.account for row in vat_return_settings.tax_accounts] - # The field names on 'Value-added Tax Return Settings' correspond to classifications - # Create a dict of these fields' values and field names + if classified_accounts is None: + classified_accounts = frappe.get_all( + "Account", + filters={"company": self.company}, + or_filters=[ + ["custom_vat_return_debit_classification", "not in", ["", None]], + ["custom_vat_return_credit_classification", "not in", ["", None]], + ], + pluck="name", + ) + taxes_and_charges_map = [ entry for entry in VAT_RETURN_SETTING_FIELD_MAP if vat_return_settings.get(entry["field_name"]) ] - vouchers = transform_gl_entries(gl_entries, tax_accounts) + vouchers = transform_gl_entries(gl_entries, tax_accounts, classified_accounts) for _voucher_no, item in vouchers.items(): voucher = item.voucher @@ -287,6 +323,7 @@ def process_gl_entries(self, gl_entries): voucher.tax_amount = 0 voucher.classification_debugging = "šŸš€" + if voucher.voucher_type in ("Sales Invoice", "Purchase Invoice"): voucher.incl_tax_amount = ( voucher.sales_invoice_taxes_total or voucher.purchase_invoice_taxes_total @@ -332,8 +369,76 @@ def process_gl_entries(self, gl_entries): else: voucher.classification_debugging += "\nšŸš€ No Taxes and Charges template on Invoice, or Taxes and Charges template is not set in 'Value-added Return Settings'" + elif voucher.voucher_type == "Expense Claim": + voucher.incl_tax_amount = ( + voucher.expense_claim_taxes_total + or voucher.expense_claim_grand_total + or voucher.general_ledger_debit + or voucher.general_ledger_credit + ) + + if voucher.incl_tax_amount and voucher.incl_tax_amount < 0 and voucher.tax_amount > 0: + voucher.tax_amount = voucher.tax_amount * -1 + + expense_types = frappe.get_all( + "Expense Claim Detail", + filters={"parent": voucher.voucher_no}, + pluck="expense_type", + ) + classifications = set() + for expense_type in expense_types: + default_account = frappe.db.get_value( + "Expense Claim Account", + {"parent": expense_type, "company": self.company}, + "default_account", + ) + if default_account: + classification = frappe.get_cached_value( + "Account", default_account, "custom_vat_return_debit_classification" + ) + if classification: + classifications.add(classification) + + if len(classifications) == 1: + voucher.classification = classifications.pop() + + voucher.classification_debugging += ( + f"\nšŸš€ voucher_type is 'Expense Claim'" + f"\nšŸš€ expense_claim_taxes_total = {voucher.expense_claim_taxes_total}" + f"\nšŸš€ expense_claim_grand_total = {voucher.expense_claim_grand_total}" + f"\nšŸš€ incl_tax_amount = {voucher.incl_tax_amount}" + f"\nšŸš€ expense_types = {expense_types}" + f"\nšŸš€ classifications found = {classifications}" + f"\nšŸš€ classification = '{voucher.classification}'" + ) + + if voucher.classification: + continue + if voucher.voucher_type == "Journal Entry": voucher.classification_debugging += "\nšŸš€ voucher_type is 'Journal Entry'" + + # Exempt / no-VAT Journal Entry: the voucher is a classified non-tax account entry + # (e.g. Interest Received with custom_vat_return_credit_classification = "Output - E Exempt"). + # tax_amount is already 0. Derive classification and incl_tax_amount from the GL Entry. + if voucher.account not in tax_accounts: + amount = voucher.general_ledger_debit or voucher.general_ledger_credit or 0 + voucher.incl_tax_amount = amount + if voucher.general_ledger_debit: + voucher.classification = frappe.get_cached_value( + "Account", voucher.account, "custom_vat_return_debit_classification" + ) + else: + voucher.classification = frappe.get_cached_value( + "Account", voucher.account, "custom_vat_return_credit_classification" + ) + voucher.classification_debugging += ( + f"\nšŸš€ [exempt JE] account='{voucher.account}'" + f"\nšŸš€ incl_tax_amount={voucher.incl_tax_amount}" + f"\nšŸš€ classification='{voucher.classification}'" + ) + continue + # Process pairs of Journal Entry Account child records # E.g. # @@ -351,6 +456,7 @@ def process_gl_entries(self, gl_entries): filtered_out = [] for journal_entry in item.linked_journal_entries: if journal_entry not in filtered_out: + contra_entry_with_same_amount = None if journal_entry.journal_entry_account_debit != 0: debit_amount = journal_entry.journal_entry_account_debit contra_entry_with_same_amount = next( @@ -375,10 +481,9 @@ def process_gl_entries(self, gl_entries): filtered_out += [contra_entry_with_same_amount, journal_entry] filtered_journal_entries = [ - item for item in item.linked_journal_entries if item not in filtered_out + je_entry for je_entry in item.linked_journal_entries if je_entry not in filtered_out ] - # If there are no entries remaining after filtering, assume it is an entry for SARS Payment/Receipt if len(filtered_journal_entries) == 0 and len(filtered_out) > 0: voucher.classification = "SARS Payment/Receipt" continue @@ -388,89 +493,150 @@ def process_gl_entries(self, gl_entries): ) voucher.classification_debugging += f"\nšŸš€ filtered_journal_entries = rows {[je.journal_entry_account_idx for je in filtered_journal_entries]}" - # Identify the tax, tax inclusive and tax exclusve components of manual Journal Entries - tax_leg = next( - (je for je in filtered_journal_entries if je.journal_entry_account in tax_accounts), None + # The voucher is the tax leg (guaranteed by transform_gl_entries). + # Isolate this GL Entry's JEA row, other tax-account JEA rows, and non-tax JEA rows. + this_gl_debit = voucher.general_ledger_debit or 0 + this_gl_credit = voucher.general_ledger_credit or 0 + + this_tax_jea = next( + ( + je + for je in filtered_journal_entries + if je.journal_entry_account == voucher.account + and abs((je.journal_entry_account_debit or 0) - this_gl_debit) < 0.01 + and abs((je.journal_entry_account_credit or 0) - this_gl_credit) < 0.01 + ), + None, ) + other_tax_jea_rows = [ + je + for je in filtered_journal_entries + if je.journal_entry_account in tax_accounts and je is not this_tax_jea + ] + non_tax_entries = [ + je + for je in filtered_journal_entries + if je not in other_tax_jea_rows and je is not this_tax_jea + ] - if tax_leg: - remaining_entries = [je for je in filtered_journal_entries if je != tax_leg] - - if tax_leg.journal_entry_account_debit != 0: - # Tax leg is a debit (input tax or reduction of output tax) - other_debits = [je for je in remaining_entries if je.journal_entry_account_debit != 0] - other_credits = [ - je for je in remaining_entries if je.journal_entry_account_credit != 0 - ] + voucher.classification_debugging += f"\nšŸš€ this_tax_jea idx = {this_tax_jea.journal_entry_account_idx if this_tax_jea else 'None'}" + voucher.classification_debugging += ( + f"\nšŸš€ other_tax_jea rows = {[je.journal_entry_account_idx for je in other_tax_jea_rows]}" + ) + voucher.classification_debugging += ( + f"\nšŸš€ non_tax_entries = {[je.journal_entry_account_idx for je in non_tax_entries]}" + ) - # This handles write-offs where there is one other debit (e.g. bad debts) - # and multiple credit entries (to customer account) - if len(other_debits) == 1 and other_credits: - excl_tax_leg = other_debits[0] - voucher.incl_tax_amount = sum( - c.journal_entry_account_credit for c in other_credits - ) + # Scenario 1: write-off pattern — 1 non-tax debit + multiple credits (or vice versa). + # Also handles standard 3-leg JEs (1 expense debit + 1 offset credit). + if this_gl_debit: + other_debits = [je for je in non_tax_entries if je.journal_entry_account_debit != 0] + other_credits = [je for je in non_tax_entries if je.journal_entry_account_credit != 0] + if len(other_debits) == 1 and other_credits: + excl_tax_leg = other_debits[0] + voucher.incl_tax_amount = sum(c.journal_entry_account_credit for c in other_credits) + voucher.classification = frappe.get_cached_value( + "Account", + excl_tax_leg.journal_entry_account, + "custom_vat_return_debit_classification", + ) + voucher.classification_debugging += ( + f"\nšŸš€ [strategy 1] excl_tax_leg = '{excl_tax_leg.journal_entry_account}'" + f"\nšŸš€ classification = '{voucher.classification}'" + ) + continue - voucher.classification_debugging += f"\nšŸš€ tax_leg = '{tax_leg.journal_entry_account}': '{tax_leg.journal_entry_account_debit}'" - voucher.classification_debugging += ( - f"\nšŸš€ incl_tax_amount = '{voucher.incl_tax_amount}'" - ) - voucher.classification_debugging += f"\nšŸš€ excl_tax_leg = '{excl_tax_leg.journal_entry_account}': '{excl_tax_leg.journal_entry_account_debit}'" + elif this_gl_credit: + other_debits = [je for je in non_tax_entries if je.journal_entry_account_debit != 0] + other_credits = [je for je in non_tax_entries if je.journal_entry_account_credit != 0] + if len(other_credits) == 1 and other_debits: + excl_tax_leg = other_credits[0] + voucher.incl_tax_amount = sum(d.journal_entry_account_debit for d in other_debits) + voucher.classification = frappe.get_cached_value( + "Account", + excl_tax_leg.journal_entry_account, + "custom_vat_return_credit_classification", + ) + voucher.classification_debugging += ( + f"\nšŸš€ [strategy 1] excl_tax_leg = '{excl_tax_leg.journal_entry_account}'" + f"\nšŸš€ classification = '{voucher.classification}'" + ) + continue + # Scenario 2: adjacent-index pairing — find the non-tax JEA row immediately + # before (or after) this tax leg by index order. Used when multiple expense + # accounts exist in the same JE (multiple VAT legs). + if this_tax_jea and non_tax_entries: + this_idx = this_tax_jea.journal_entry_account_idx or 0 + non_tax_sorted = sorted(non_tax_entries, key=lambda je: je.journal_entry_account_idx or 0) + if this_gl_debit: + preceding = [ + je + for je in non_tax_sorted + if (je.journal_entry_account_idx or 0) < this_idx + and je.journal_entry_account_debit != 0 + ] + following = [ + je + for je in non_tax_sorted + if (je.journal_entry_account_idx or 0) > this_idx + and je.journal_entry_account_debit != 0 + ] + else: + preceding = [ + je + for je in non_tax_sorted + if (je.journal_entry_account_idx or 0) < this_idx + and je.journal_entry_account_credit != 0 + ] + following = [ + je + for je in non_tax_sorted + if (je.journal_entry_account_idx or 0) > this_idx + and je.journal_entry_account_credit != 0 + ] + adjacent = (preceding[-1] if preceding else None) or (following[0] if following else None) + if adjacent: + excl_amount = ( + adjacent.journal_entry_account_debit or adjacent.journal_entry_account_credit or 0 + ) + voucher.incl_tax_amount = excl_amount + (this_gl_debit or this_gl_credit) + if this_gl_debit: voucher.classification = frappe.get_cached_value( "Account", - excl_tax_leg.journal_entry_account, + adjacent.journal_entry_account, "custom_vat_return_debit_classification", ) - voucher.classification_debugging += f"\nšŸš€ 'Classify Debit entries...' setting for Account '{excl_tax_leg.journal_entry_account}' = '{voucher.classification}'" - continue - - elif tax_leg.journal_entry_account_credit != 0: - # Tax leg is a credit (output tax) - other_debits = [je for je in remaining_entries if je.journal_entry_account_debit != 0] - other_credits = [ - je for je in remaining_entries if je.journal_entry_account_credit != 0 - ] - - # This handles cases with one other credit and multiple debit entries - if len(other_credits) == 1 and other_debits: - excl_tax_leg = other_credits[0] - voucher.incl_tax_amount = sum(d.journal_entry_account_debit for d in other_debits) - - voucher.classification_debugging += f"\nšŸš€ tax_leg = '{tax_leg.journal_entry_account}': '{tax_leg.journal_entry_account_credit}'" - voucher.classification_debugging += ( - f"\nšŸš€ incl_tax_amount = '{voucher.incl_tax_amount}'" - ) - voucher.classification_debugging += f"\nšŸš€ excl_tax_leg = '{excl_tax_leg.journal_entry_account}': '{excl_tax_leg.journal_entry_account_credit}'" - + else: voucher.classification = frappe.get_cached_value( "Account", - excl_tax_leg.journal_entry_account, + adjacent.journal_entry_account, "custom_vat_return_credit_classification", ) - voucher.classification_debugging += f"\nšŸš€ 'Classify Credit entries...' setting for Account '{excl_tax_leg.journal_entry_account}' = '{voucher.classification}'" - continue + voucher.classification_debugging += ( + f"\nšŸš€ [strategy 2] adjacent idx={adjacent.journal_entry_account_idx}" + f" account='{adjacent.journal_entry_account}'" + f"\nšŸš€ classification = '{voucher.classification}'" + ) + continue - # Fallback to old logic for simple 3-leg entries. + # Scenario 3: min/max heuristic — fallback for entries where secanrios 1 and 2 + # did not resolve (e.g. no clear single-debit / single-credit pattern). incl_tax_leg = None excl_tax_leg = None try: incl_tax_leg = max( - [je for je in filtered_journal_entries if je != tax_leg], + non_tax_entries, key=lambda je: abs(je.journal_entry_account_credit or je.journal_entry_account_debit), ) excl_tax_leg = min( - [je for je in filtered_journal_entries if je != tax_leg], + non_tax_entries, key=lambda je: abs(je.journal_entry_account_credit or je.journal_entry_account_debit), ) except (ValueError, TypeError) as e: - voucher.classification_debugging += f"\nšŸš€ {e}]'" - - if all([tax_leg, incl_tax_leg, excl_tax_leg]): - voucher.classification_debugging += f"\nšŸš€ tax_leg = '{tax_leg.journal_entry_account}': '{tax_leg.journal_entry_account_credit or tax_leg.journal_entry_account_debit}'" - voucher.classification_debugging += f"\nšŸš€ incl_tax_leg = '{incl_tax_leg.journal_entry_account}': '{incl_tax_leg.journal_entry_account_credit or incl_tax_leg.journal_entry_account_debit}'" - voucher.classification_debugging += f"\nšŸš€ excl_tax_leg = '{excl_tax_leg.journal_entry_account}': '{excl_tax_leg.journal_entry_account_credit or excl_tax_leg.journal_entry_account_debit}'" + voucher.classification_debugging += f"\nšŸš€ {e}" + if incl_tax_leg and excl_tax_leg: if excl_tax_leg.journal_entry_account_debit != 0: voucher.classification = frappe.get_cached_value( "Account", @@ -481,7 +647,10 @@ def process_gl_entries(self, gl_entries): incl_tax_leg.journal_entry_account_credit or incl_tax_leg.journal_entry_account_debit ) - voucher.classification_debugging += f"\nšŸš€ 'Classify Debit entries...' setting for Account '{excl_tax_leg.journal_entry_account}' = '{voucher.classification}'" + voucher.classification_debugging += ( + f"\nšŸš€ [strategy 3] excl_tax_leg = '{excl_tax_leg.journal_entry_account}'" + f"\nšŸš€ classification = '{voucher.classification}'" + ) continue elif excl_tax_leg.journal_entry_account_credit != 0: voucher.classification = frappe.get_cached_value( @@ -493,72 +662,74 @@ def process_gl_entries(self, gl_entries): incl_tax_leg.journal_entry_account_credit or incl_tax_leg.journal_entry_account_debit ) - voucher.classification_debugging += f"\nšŸš€ 'Classify Credit entries..' for Account '{excl_tax_leg.journal_entry_account}' = '{voucher.classification}'" + voucher.classification_debugging += ( + f"\nšŸš€ [strategy 3] excl_tax_leg = '{excl_tax_leg.journal_entry_account}'" + f"\nšŸš€ classification = '{voucher.classification}'" + ) continue return [voucher.voucher for voucher in vouchers.values()] -def transform_gl_entries(gl_entries, tax_accounts): +def transform_gl_entries(gl_entries, tax_accounts, classified_accounts=None): """ - Transform flat list of entries to a dict with voucher_no as key - E.g. - - [ - { - "journal_entry_total_credit": 15850, - "journal_entry_total_debit": 15850, - "name": "ACC-GLE-2024-11691", - "posting_date": "2024-03-01", - . - . - . - "voucher_no": "ACC-JV-2024-00835", - "voucher_type": "Journal Entry", - } - ] - - becomes - - [ - { - "ACC-JV-2024-00835": - { - "voucher": { - "journal_entry_total_credit": 15850, - "journal_entry_total_debit": 15850, - "name": "ACC-GLE-2024-11691", - "posting_date": "2024-03-01", - . - . - . - "voucher_no": "ACC-JV-2024-00835", - "voucher_type": "Journal Entry", - } - "linked_journal_entries": [ - . - . - . - ] - } - } - ] + Transform flat list of GL Entry rows into a dict of vouchers. + + For Journal Entries: one entry per tax-account GL Entry (keyed by gle.name). + All entries for the same JE share a deduplicated linked_journal_entries list. + For Sales/Purchase Invoices: one entry per voucher_no (existing behaviour). """ + tax_accounts_set = set(tax_accounts) + classified_accounts_set = set(classified_accounts or []) + + # Pass 1: collect unique JEA rows per JE voucher_no, deduplicated by idx. + # The query cross-joins every GLE row with every JEA row, producing duplicates. + je_jea_rows = {} # {voucher_no: {idx: entry}} + for entry in gl_entries: + if entry.voucher_type == "Journal Entry": + vno = entry.voucher_no + idx = entry.journal_entry_account_idx + if vno not in je_jea_rows: + je_jea_rows[vno] = {} + if idx not in je_jea_rows[vno]: + je_jea_rows[vno][idx] = entry + + # Pass 2: build the vouchers dict. vouchers = {} + je_has_tax_leg = set() # voucher_nos that have at least one tax-account GL Entry + je_classified_non_tax = {} # {voucher_no: [GL Entry rows for classified non-tax accounts]} for entry in gl_entries: - voucher_no = entry.voucher_no - if voucher_no not in vouchers: - vouchers[voucher_no] = frappe._dict({"voucher": entry, "linked_journal_entries": []}) + vno = entry.voucher_no + if entry.voucher_type == "Journal Entry": + if entry.account in tax_accounts_set: + je_has_tax_leg.add(vno) + key = entry.name + if key not in vouchers: + linked = list(je_jea_rows.get(vno, {}).values()) + vouchers[key] = frappe._dict({"voucher": entry, "linked_journal_entries": linked}) + elif entry.account in classified_accounts_set: + je_classified_non_tax.setdefault(vno, []).append(entry) else: - # If the new entry is from a tax account, and the old one is not, then it becomes the main voucher - if ( - hasattr(entry, "account") - and hasattr(vouchers[voucher_no].voucher, "account") - and entry.account in tax_accounts - and vouchers[voucher_no].voucher.account not in tax_accounts - ): - vouchers[voucher_no].voucher = entry - - vouchers[voucher_no]["linked_journal_entries"].append(entry) + if vno not in vouchers: + vouchers[vno] = frappe._dict({"voucher": entry, "linked_journal_entries": []}) + else: + if ( + hasattr(entry, "account") + and hasattr(vouchers[vno].voucher, "account") + and entry.account in tax_accounts_set + and vouchers[vno].voucher.account not in tax_accounts_set + ): + vouchers[vno].voucher = entry + vouchers[vno]["linked_journal_entries"].append(entry) + + # Pass 3: for JEs with no tax leg, add each classified non-tax entry as its own voucher. + # JEs that already have a tax leg are handled above — skip them to avoid double-counting. + for vno, entries in je_classified_non_tax.items(): + if vno not in je_has_tax_leg: + for entry in entries: + key = entry.name + if key not in vouchers: + linked = list(je_jea_rows.get(vno, {}).values()) + vouchers[key] = frappe._dict({"voucher": entry, "linked_journal_entries": linked}) return vouchers diff --git a/package.json b/package.json index 737b697..319a040 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "csf_za", - "version": "0.2.7", + "version": "0.3.0", "author": "Starktail (Pty) Ltd ", "main": "index.js", "devDependencies": {