From 54d3200efa75c1fd5635038d8274ab8d39ab90ba Mon Sep 17 00:00:00 2001 From: Bibin <17405044+bibinqcs@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:28:45 +0000 Subject: [PATCH 1/7] feat: Enhance UAE VAT Reports and Add VAT Register - Updated the UAE VAT 201 report HTML to improve layout and styling for better readability. - Modified the JavaScript for the UAE VAT 201 report to include additional formatting for VAT legends. - Enhanced the Python logic in the UAE VAT 201 report to include caching for performance improvements and added calculations for net VAT due. - Introduced a new UAE VAT Register report with filters for company, date range, document type, and item-wise details. - Implemented SQL queries in the UAE VAT Register to fetch sales and purchase invoice data based on selected filters. - Added a new field for "Company Name in Arabic" in the Company doctype for compliance with local regulations. --- erpnext/patches.txt | 1 + .../v16_0/add_arabic_company_name_field.py | 10 + .../doctype/fta_audit_file/__init__.py | 0 .../doctype/fta_audit_file/fta_audit_file.js | 123 ++++ .../fta_audit_file/fta_audit_file.json | 221 ++++++ .../doctype/fta_audit_file/fta_audit_file.py | 659 ++++++++++++++++++ .../fta_audit_file/test_fta_audit_file.py | 258 +++++++ .../report/uae_vat_201/uae_vat_201.html | 141 ++-- .../report/uae_vat_201/uae_vat_201.js | 5 +- .../report/uae_vat_201/uae_vat_201.py | 321 ++++++++- .../report/uae_vat_register/__init__.py | 0 .../uae_vat_register/uae_vat_register.js | 73 ++ .../uae_vat_register/uae_vat_register.json | 24 + .../uae_vat_register/uae_vat_register.py | 216 ++++++ .../regional/united_arab_emirates/setup.py | 8 + 15 files changed, 1986 insertions(+), 74 deletions(-) create mode 100644 erpnext/patches/v16_0/add_arabic_company_name_field.py create mode 100644 erpnext/regional/doctype/fta_audit_file/__init__.py create mode 100644 erpnext/regional/doctype/fta_audit_file/fta_audit_file.js create mode 100644 erpnext/regional/doctype/fta_audit_file/fta_audit_file.json create mode 100644 erpnext/regional/doctype/fta_audit_file/fta_audit_file.py create mode 100644 erpnext/regional/doctype/fta_audit_file/test_fta_audit_file.py create mode 100644 erpnext/regional/report/uae_vat_register/__init__.py create mode 100644 erpnext/regional/report/uae_vat_register/uae_vat_register.js create mode 100644 erpnext/regional/report/uae_vat_register/uae_vat_register.json create mode 100644 erpnext/regional/report/uae_vat_register/uae_vat_register.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 5db549806a3..19f2451576e 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -489,3 +489,4 @@ erpnext.patches.v16_0.submit_existing_product_bundles #1 erpnext.patches.v16_0.migrate_subscription_generate_invoice_at erpnext.patches.v16_0.rename_subscription_billing_period_fields erpnext.patches.v16_0.drop_redundant_serial_no_index_from_sabb +erpnext.patches.v16_0.add_arabic_company_name_field diff --git a/erpnext/patches/v16_0/add_arabic_company_name_field.py b/erpnext/patches/v16_0/add_arabic_company_name_field.py new file mode 100644 index 00000000000..57b8db59f97 --- /dev/null +++ b/erpnext/patches/v16_0/add_arabic_company_name_field.py @@ -0,0 +1,10 @@ +import frappe + +from erpnext.regional.united_arab_emirates.setup import make_custom_fields + + +def execute(): + if not frappe.db.get_value("Company", {"country": "United Arab Emirates"}): + return + + make_custom_fields() diff --git a/erpnext/regional/doctype/fta_audit_file/__init__.py b/erpnext/regional/doctype/fta_audit_file/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/regional/doctype/fta_audit_file/fta_audit_file.js b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.js new file mode 100644 index 00000000000..5240da2e314 --- /dev/null +++ b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.js @@ -0,0 +1,123 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("FTA Audit File", { + refresh: function (frm) { + // Add Generate FAF button for Draft status + if (frm.doc.status === "Draft" && !frm.is_new()) { + frm.add_custom_button( + __("Generate FAF"), + function () { + frm.trigger("generate_faf"); + }, + __("Actions") + ); + } + + // Add Mark as Submitted button for Generated status + if (frm.doc.status === "Generated") { + frm.add_custom_button( + __("Mark as Submitted"), + function () { + frm.trigger("mark_submitted"); + }, + __("Actions") + ); + } + + // Add Download button if file exists + if (frm.doc.faf_file) { + frm.add_custom_button( + __("Download FAF"), + function () { + window.open(frm.doc.faf_file); + }, + __("Actions") + ); + } + + // Show status indicator + frm.trigger("set_status_indicator"); + }, + + generate_faf: function (frm) { + frappe.confirm( + __("This will generate the FTA Audit File for the selected period. Continue?"), + function () { + frm.call({ + doc: frm.doc, + method: "generate_faf", + freeze: true, + freeze_message: __("Queuing FTA Audit File generation..."), + }).then((r) => { + if (!r.message) return; + if (r.message.success) { + frappe.show_alert({ + message: r.message.message, + indicator: "green", + }); + } else { + frappe.msgprint({ + title: __("Generation Failed"), + message: r.message.message, + indicator: "red", + }); + } + frm.reload_doc(); + }); + } + ); + }, + + mark_submitted: function (frm) { + frappe.confirm( + __("Mark this FAF as submitted to FTA? This action is for record-keeping only."), + function () { + frm.call({ + doc: frm.doc, + method: "mark_as_submitted", + }).then((r) => { + if (r.message && r.message.success) { + frappe.show_alert({ + message: r.message.message, + indicator: "green", + }); + frm.reload_doc(); + } + }); + } + ); + }, + + set_status_indicator: function (frm) { + const status_colors = { + Draft: "orange", + Queued: "yellow", + Generating: "blue", + Generated: "green", + Submitted: "blue", + Error: "red", + }; + + if (frm.doc.status) { + frm.page.set_indicator(__(frm.doc.status), status_colors[frm.doc.status] || "gray"); + } + }, + + from_date: function (frm) { + frm.trigger("validate_dates"); + }, + + to_date: function (frm) { + frm.trigger("validate_dates"); + }, + + validate_dates: function (frm) { + if (frm.doc.from_date && frm.doc.to_date) { + if (frm.doc.from_date > frm.doc.to_date) { + frappe.msgprint(__("From Date cannot be after To Date")); + frm.set_value("to_date", null); + } + } + }, +}); diff --git a/erpnext/regional/doctype/fta_audit_file/fta_audit_file.json b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.json new file mode 100644 index 00000000000..db64126ae59 --- /dev/null +++ b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.json @@ -0,0 +1,221 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "naming_series:", + "beta": 1, + "creation": "2025-12-11 00:29:21.891860", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "naming_series", + "company", + "column_break_1", + "from_date", + "to_date", + "section_break_agent", + "tax_agency_name", + "tan", + "column_break_agent", + "tax_agent_name", + "taan", + "section_break_options", + "file_type", + "include_opening_balance", + "column_break_options_2", + "status", + "section_break_output", + "faf_file", + "section_break_logs", + "generation_log", + "error_message" + ], + "fields": [ + { + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Series", + "options": "FAF-.YYYY.-", + "reqd": 1, + "hidden": 1, + "default": "FAF-.YYYY.-" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "fieldname": "from_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "From Date", + "reqd": 1 + }, + { + "fieldname": "to_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "To Date", + "reqd": 1 + }, + { + "fieldname": "section_break_agent", + "fieldtype": "Section Break", + "label": "Tax Agent (optional)", + "description": "Fill in only if the FAF is being filed through a registered Tax Agency or Tax Agent.", + "collapsible": 1, + "collapsible_depends_on": "eval:!(doc.tax_agency_name || doc.tan || doc.tax_agent_name || doc.taan)" + }, + { + "fieldname": "tax_agency_name", + "fieldtype": "Data", + "label": "Tax Agency Name", + "length": 100 + }, + { + "fieldname": "tan", + "fieldtype": "Data", + "label": "TAN (Tax Agency Number)", + "length": 20 + }, + { + "fieldname": "column_break_agent", + "fieldtype": "Column Break" + }, + { + "fieldname": "tax_agent_name", + "fieldtype": "Data", + "label": "Tax Agent Name", + "length": 100 + }, + { + "fieldname": "taan", + "fieldtype": "Data", + "label": "TAAN (Tax Agent Approval Number)", + "length": 20 + }, + { + "fieldname": "section_break_options", + "fieldtype": "Section Break", + "label": "Options" + }, + { + "fieldname": "file_type", + "fieldtype": "Select", + "label": "File Type", + "options": "VAT\nExcise", + "default": "VAT", + "reqd": 1 + }, + { + "fieldname": "include_opening_balance", + "fieldtype": "Check", + "label": "Include Opening Balance in GL", + "description": "Carry each account's pre-period balance forward into the General Ledger Balance column. Off by default (period-only running balance), matching the lighter interpretation used by Microsoft Dynamics 365 Finance.", + "default": "0" + }, + { + "fieldname": "column_break_options_2", + "fieldtype": "Column Break" + }, + { + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "options": "Draft\nQueued\nGenerating\nGenerated\nSubmitted\nError", + "default": "Draft", + "in_list_view": 1, + "read_only": 1 + }, + { + "fieldname": "section_break_output", + "fieldtype": "Section Break", + "label": "Generated File", + "depends_on": "eval:doc.status === 'Generated' || doc.status === 'Submitted'" + }, + { + "fieldname": "faf_file", + "fieldtype": "Attach", + "label": "FAF File", + "read_only": 1 + }, + { + "fieldname": "section_break_logs", + "fieldtype": "Section Break", + "label": "Generation Logs", + "collapsible": 1 + }, + { + "fieldname": "generation_log", + "fieldtype": "Long Text", + "label": "Generation Log", + "read_only": 1 + }, + { + "fieldname": "error_message", + "fieldtype": "Long Text", + "label": "Error Message", + "read_only": 1, + "depends_on": "eval:doc.status === 'Error'" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-12-11 00:29:21.891860", + "modified_by": "Administrator", + "module": "Regional", + "name": "FTA Audit File", + "naming_rule": "By \"Naming Series\" field", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "company", + "track_changes": 1 +} diff --git a/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py new file mode 100644 index 00000000000..fd8d937735e --- /dev/null +++ b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py @@ -0,0 +1,659 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +""" +FTA Audit File DocType Controller + +Owns generation of FTA Audit Files (FAF) for UAE VAT compliance per the +FTA "Requirements Document for Tax Accounting Software" (October 2017), +Appendix 5. + +A conformant VAT FAF contains four CSV tables in this order, each +delimited by an explicit start/end marker row: + + 1. Company Information (CompInfoStart .. CompInfoEnd) + 2. Purchase Listing (PurcDataStart .. PurcDataEnd) + 3. Supply Listing (SuppDataStart .. SuppDataEnd) + 4. General Ledger (GLDataStart .. GLDataEnd) + +The footer of each transactional table carries running totals plus a +transaction count. All amounts are in AED (foreign-currency mirrors are +emitted alongside when the source invoice is non-AED). +""" + +import csv +import io + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import flt, getdate, today +from frappe.utils.file_manager import save_file + +FAF_VERSION = "FAFv1.0.0" +DEFAULT_COUNTRY = "United Arab Emirates" +DEFAULT_DATE = "31-12-9999" +PRODUCT_VERSION = "ERPNext" + + +class FTAAuditFile(Document): + def validate(self): + if getdate(self.from_date) > getdate(self.to_date): + frappe.throw(_("From Date cannot be after To Date")) + + if not frappe.db.get_value("Company", self.company, "tax_id"): + frappe.throw( + _("Company {0} does not have a Tax ID (TRN). Please set the Tax ID in Company.").format( + self.company + ) + ) + + if self.status != "Error": + self.error_message = None + + @frappe.whitelist() + def generate_faf(self): + """Queue FAF generation as a background job. + + Returns immediately with status ``Queued``. The actual generation + runs in ``_run_generation`` on the ``long`` queue (Frappe's + ``enqueue_doc`` re-fetches a fresh doc inside the worker) and + updates ``status``, ``faf_file``, ``generation_log``, and + ``error_message`` when complete. + + Under ``frappe.flags.in_test`` the job runs synchronously so tests + can assert on the post-generation state without polling. + """ + self.status = "Queued" + self.generation_log = "" + self.error_message = None + self.save() + + frappe.enqueue_doc( + self.doctype, + self.name, + "_run_generation", + queue="long", + timeout=1500, + enqueue_after_commit=True, + now=bool(frappe.flags.in_test), + ) + + return { + "success": True, + "message": _("FAF generation has been queued. The status will update when complete."), + "docname": self.name, + "status": self.status, + } + + @frappe.whitelist() + def mark_as_submitted(self): + """Mark the FAF as submitted to the FTA portal (manual record).""" + if self.status != "Generated": + frappe.throw(_("Only Generated files can be marked as Submitted")) + self.status = "Submitted" + self.save() + return {"success": True, "message": _("FAF marked as submitted")} + + def _run_generation(self): + """Background entry point. Invoked via ``frappe.enqueue_doc`` from + ``generate_faf`` (or synchronously under ``frappe.flags.in_test``). + + Broad ``except`` is intentional: a long-running batch op records + the failure on the doc itself (status/error_message/log) so the + user sees what went wrong without the request 500'ing. The + exception is re-raised so the queue marks the job as failed and + the traceback is written to the Error Log. + """ + try: + self.status = "Generating" + self.save() + + result = self._build_faf() + self.faf_file = result["file_url"] + self.generation_log = result["log"] + self.status = "Generated" + self.save() + except Exception as e: + try: + err_doc = frappe.get_doc(self.doctype, self.name) + err_doc.status = "Error" + err_doc.error_message = str(e) + err_doc.generation_log = (err_doc.generation_log or "") + f"\n\nError: {e}" + err_doc.save() + except Exception: + pass + frappe.log_error( + title=_("FAF Generation Error"), + message=frappe.get_traceback(), + ) + raise + + def _build_faf(self): + """Build the FAF CSV per Appendix 5 and attach it to this document.""" + log_entries = [] + + def log(msg): + log_entries.append(msg) + + log(f"Starting FAF generation for {self.company}") + log(f"Period: {self.from_date} to {self.to_date}") + log(f"File Type: {self.file_type}") + + if self.file_type != "VAT": + frappe.throw(_("FAF generation for {0} is not yet implemented").format(self.file_type)) + + output = io.StringIO() + writer = csv.writer(output) + + self._write_company_info(writer) + log("Company Information written") + + purchase_count = self._write_purchase_listing(writer) + log(f"Purchase Listing written: {purchase_count} line items") + + supply_count = self._write_supply_listing(writer) + log(f"Supply Listing written: {supply_count} line items") + + gl_count = self._write_gl_listing(writer) + log(f"General Ledger written: {gl_count} entries") + + csv_content = output.getvalue() + output.close() + + file_name = f"FAF_{self.company}_{self.from_date}_to_{self.to_date}.csv".replace(" ", "_") + file_doc = save_file( + fname=file_name, + content=csv_content.encode("utf-8"), + dt="FTA Audit File", + dn=self.name, + is_private=1, + ) + + log(f"FAF file generated: {file_name}") + log("Generation completed successfully") + + return {"file_url": file_doc.file_url, "log": "\n".join(log_entries)} + + def _write_company_info(self, writer): + """Emit ``CompInfoStart`` + body row + ``CompInfoEnd`` per Appendix 5.""" + writer.writerow(["CompInfoStart"]) + + info = ( + frappe.db.get_value( + "Company", + self.company, + ["company_name", "company_name_in_arabic", "tax_id"], + as_dict=True, + ) + or {} + ) + + writer.writerow( + [ + _clean(info.get("company_name") or self.company), + _clean(info.get("company_name_in_arabic") or ""), + info.get("tax_id") or "", + _clean(self.tax_agency_name or ""), + _clean(self.tan or ""), + _clean(self.tax_agent_name or ""), + _clean(self.taan or ""), + _format_date(self.from_date), + _format_date(self.to_date), + _format_date(today()), + PRODUCT_VERSION, + FAF_VERSION, + ] + ) + + writer.writerow(["CompInfoEnd"]) + + def _write_purchase_listing(self, writer): + """Emit Purchase Listing per Appendix 5 with end-of-table totals row.""" + writer.writerow(["PurcDataStart"]) + + invoices = frappe.get_all( + "Purchase Invoice", + filters={ + "company": self.company, + "posting_date": ["between", [self.from_date, self.to_date]], + "docstatus": 1, + }, + fields=[ + "name", + "supplier", + "supplier_name", + "posting_date", + "permit_no", + "currency", + "conversion_rate", + ], + order_by="posting_date asc, name asc", + ) + if not invoices: + writer.writerow(["PurcDataEnd", _money(0), _money(0), 0]) + return 0 + + invoice_names = [inv.name for inv in invoices] + supplier_names = list({inv.supplier for inv in invoices if inv.supplier}) + + supplier_trn_map = _bulk_party_field("Supplier", supplier_names, "tax_id") + items_by_invoice = _bulk_invoice_items( + "Purchase Invoice Item", + invoice_names, + [ + "parent", + "idx", + "item_name", + "description", + "base_net_amount", + "net_amount", + "tax_amount", + "item_tax_template", + ], + ) + tax_code_map = { + t: _resolve_tax_code(t) + for t in {item.item_tax_template for items in items_by_invoice.values() for item in items} + if t + } + + company_currency = _company_currency(self.company) + + total_purchase_aed = 0.0 + total_vat_aed = 0.0 + line_count = 0 + + for inv in invoices: + supplier_trn = supplier_trn_map.get(inv.supplier, "") + fcy_code, fcy_factor = _fcy_for_invoice(inv.currency, inv.conversion_rate, company_currency) + + for item in items_by_invoice.get(inv.name, []): + net_aed = flt(item.base_net_amount, 2) + vat_aed = flt(item.tax_amount or 0, 2) + net_fcy = flt((item.net_amount or 0) if fcy_code != "XXX" else 0, 2) + vat_fcy = flt(vat_aed / fcy_factor if fcy_factor else 0, 2) if fcy_code != "XXX" else 0.0 + + writer.writerow( + [ + _clean(inv.supplier_name), + supplier_trn, + _format_date(inv.posting_date), + inv.name, + inv.permit_no or "", + item.idx, + _clean(item.description or item.item_name or ""), + _money(net_aed), + _money(vat_aed), + tax_code_map.get(item.item_tax_template, "SR"), + fcy_code, + _money(net_fcy), + _money(vat_fcy), + ] + ) + total_purchase_aed += net_aed + total_vat_aed += vat_aed + line_count += 1 + + writer.writerow( + [ + "PurcDataEnd", + _money(total_purchase_aed), + _money(total_vat_aed), + line_count, + ] + ) + return line_count + + def _write_supply_listing(self, writer): + """Emit Supply Listing per Appendix 5 with end-of-table totals row.""" + writer.writerow(["SuppDataStart"]) + + invoices = frappe.get_all( + "Sales Invoice", + filters={ + "company": self.company, + "posting_date": ["between", [self.from_date, self.to_date]], + "docstatus": 1, + }, + fields=[ + "name", + "customer", + "customer_name", + "posting_date", + "currency", + "conversion_rate", + ], + order_by="posting_date asc, name asc", + ) + if not invoices: + writer.writerow(["SuppDataEnd", _money(0), _money(0), 0]) + return 0 + + invoice_names = [inv.name for inv in invoices] + customer_names = list({inv.customer for inv in invoices if inv.customer}) + + customer_trn_map = _bulk_party_field("Customer", customer_names, "tax_id") + customer_country_map = _bulk_party_country("Customer", customer_names) + items_by_invoice = _bulk_invoice_items( + "Sales Invoice Item", + invoice_names, + [ + "parent", + "idx", + "item_name", + "description", + "base_net_amount", + "net_amount", + "tax_amount", + "item_tax_template", + "is_zero_rated", + "is_exempt", + ], + ) + tax_code_map = { + t: _resolve_tax_code(t) + for t in {item.item_tax_template for items in items_by_invoice.values() for item in items} + if t + } + + company_currency = _company_currency(self.company) + + total_supply_aed = 0.0 + total_vat_aed = 0.0 + line_count = 0 + + for inv in invoices: + customer_trn = customer_trn_map.get(inv.customer, "") + customer_country = customer_country_map.get(inv.customer) or DEFAULT_COUNTRY + fcy_code, fcy_factor = _fcy_for_invoice(inv.currency, inv.conversion_rate, company_currency) + + for item in items_by_invoice.get(inv.name, []): + net_aed = flt(item.base_net_amount, 2) + vat_aed = flt(item.tax_amount or 0, 2) + net_fcy = flt((item.net_amount or 0) if fcy_code != "XXX" else 0, 2) + vat_fcy = flt(vat_aed / fcy_factor if fcy_factor else 0, 2) if fcy_code != "XXX" else 0.0 + + if item.is_zero_rated: + tax_code = "ZR" + elif item.is_exempt: + tax_code = "EX" + else: + tax_code = tax_code_map.get(item.item_tax_template, "SR") + + writer.writerow( + [ + _clean(inv.customer_name), + customer_trn, + _format_date(inv.posting_date), + inv.name, + item.idx, + _clean(item.description or item.item_name or ""), + _money(net_aed), + _money(vat_aed), + tax_code, + _clean(customer_country), + fcy_code, + _money(net_fcy), + _money(vat_fcy), + ] + ) + total_supply_aed += net_aed + total_vat_aed += vat_aed + line_count += 1 + + writer.writerow( + [ + "SuppDataEnd", + _money(total_supply_aed), + _money(total_vat_aed), + line_count, + ] + ) + return line_count + + def _write_gl_listing(self, writer): + """Emit General Ledger per Appendix 5 with end-of-table totals row.""" + writer.writerow(["GLDataStart"]) + + entries = frappe.get_all( + "GL Entry", + filters={ + "company": self.company, + "posting_date": ["between", [self.from_date, self.to_date]], + "is_cancelled": 0, + }, + fields=[ + "name", + "posting_date", + "account", + "remarks", + "against", + "voucher_no", + "voucher_type", + "debit", + "credit", + ], + order_by="posting_date asc, creation asc", + ) + if not entries: + writer.writerow(["GLDataEnd", _money(0), _money(0), 0, "AED"]) + return 0 + + account_names = list({e.account for e in entries if e.account}) + account_name_map = _bulk_party_field("Account", account_names, "account_name") + + if self.include_opening_balance: + running_balance = _opening_balances_by_account(self.company, self.from_date, account_names) + else: + running_balance = {} + + source_type_map = { + "Sales Invoice": "AR", + "Purchase Invoice": "AP", + "Journal Entry": "General Journal", + "Payment Entry": "Cash Receipt", + "Stock Entry": "Inventory", + "Delivery Note": "Inventory Sale", + "Purchase Receipt": "Purchases", + } + + total_debit = 0.0 + total_credit = 0.0 + count = 0 + + for entry in entries: + account_name = account_name_map.get(entry.account) or entry.account + source_type = source_type_map.get(entry.voucher_type, entry.voucher_type or "") + debit = flt(entry.debit, 2) + credit = flt(entry.credit, 2) + + running_balance[entry.account] = running_balance.get(entry.account, 0.0) + debit - credit + balance = flt(running_balance[entry.account], 2) + + writer.writerow( + [ + _format_date(entry.posting_date), + entry.account, + _clean(account_name), + _clean(entry.remarks or ""), + _clean(entry.against or ""), + entry.voucher_no, + entry.voucher_no, + source_type, + _money(debit), + _money(credit), + _money(balance), + ] + ) + total_debit += debit + total_credit += credit + count += 1 + + writer.writerow( + [ + "GLDataEnd", + _money(total_debit), + _money(total_credit), + count, + "AED", + ] + ) + return count + + +def _clean(value): + """Sanitize a string for FAF CSV. + + The spec mandates that the delimiter (``,``) must not appear inside any + field. We follow Microsoft Dynamics 365's UAE FAF convention and + substitute ``;`` so the original separator stays visible in the data. + Embedded newlines are stripped because they would otherwise break the + CSV row structure. + """ + if value is None: + return "" + return str(value).replace(",", ";").replace("\n", " ").replace("\r", " ").strip() + + +def _format_date(d): + """Format a date as DD-MM-YYYY per FTA spec; missing values become 31-12-9999.""" + if not d: + return DEFAULT_DATE + return getdate(d).strftime("%d-%m-%Y") + + +def _money(value): + """Format a numeric field as ``Decimal[14,2]`` per FTA spec. + + Python's ``csv.writer`` calls ``str()`` on numeric values, which + strips trailing zeros (``0.00`` → ``"0.0"``). The spec mandates two + decimal places everywhere a Decimal[14,2] field is emitted, so we + pre-format to a string here. + """ + return f"{flt(value):.2f}" + + +def _company_currency(company): + return frappe.db.get_value("Company", company, "default_currency") or "AED" + + +def _fcy_for_invoice(invoice_currency, conversion_rate, company_currency): + """Resolve foreign-currency code + conversion factor for an invoice. + + Returns ``("XXX", 1.0)`` when the invoice is in the company's home + currency (no FCY columns to populate); otherwise the ISO 4217 code + plus the conversion factor (rate to company currency). + """ + if not invoice_currency or invoice_currency == company_currency: + return ("XXX", 1.0) + return (invoice_currency, flt(conversion_rate) or 1.0) + + +def _bulk_party_field(doctype, names, field): + """Return ``{name: field_value}`` for the given names. Empty input → empty dict.""" + if not names: + return {} + rows = frappe.get_all( + doctype, + filters={"name": ["in", names]}, + fields=["name", field], + ) + return {r["name"]: (r.get(field) or "") for r in rows} + + +def _bulk_party_country(party_doctype, party_names): + """Return ``{party_name: country}`` taken from each party's first address with a country.""" + if not party_names: + return {} + + dl = frappe.qb.DocType("Dynamic Link") + addr = frappe.qb.DocType("Address") + rows = ( + frappe.qb.from_(dl) + .inner_join(addr) + .on(addr.name == dl.parent) + .where(dl.link_doctype == party_doctype) + .where(dl.parenttype == "Address") + .where(dl.link_name.isin(party_names)) + .where(addr.country.isnotnull()) + .where(addr.country != "") + .select(dl.link_name, addr.country) + .run(as_dict=True) + ) + out = {} + for r in rows: + out.setdefault(r["link_name"], r["country"]) + return out + + +def _opening_balances_by_account(company, period_start, accounts): + """Net pre-period balance per account: sum(debit) - sum(credit) before ``period_start``. + + Returns ``{account: net}`` where net is debit-positive (positive for + asset/expense accounts that carry a debit balance, negative for + liability/equity/revenue accounts that carry a credit balance). + Single aggregated SQL via ``frappe.qb`` — one query regardless of the + number of accounts. Cancelled GL entries are excluded. + """ + if not accounts: + return {} + + from frappe.query_builder.functions import Sum + + gle = frappe.qb.DocType("GL Entry") + rows = ( + frappe.qb.from_(gle) + .where(gle.company == company) + .where(gle.posting_date < period_start) + .where(gle.is_cancelled == 0) + .where(gle.account.isin(accounts)) + .groupby(gle.account) + .select( + gle.account, + Sum(gle.debit).as_("debit"), + Sum(gle.credit).as_("credit"), + ) + .run(as_dict=True) + ) + return {r["account"]: flt(r.get("debit") or 0) - flt(r.get("credit") or 0) for r in rows} + + +def _bulk_invoice_items(child_doctype, invoice_names, fields): + """Return ``{parent_invoice: [items...]}`` for the given invoice names.""" + if not invoice_names: + return {} + items = frappe.get_all( + child_doctype, + filters={"parent": ["in", invoice_names]}, + fields=fields, + order_by="parent asc, idx asc", + ) + out = {} + for item in items: + out.setdefault(item["parent"], []).append(item) + return out + + +_FTA_TAX_CODES = ("SR", "ZR", "EX", "RC", "IG", "OA", "IA") + + +def _resolve_tax_code(item_tax_template): + """Derive FTA tax code (SR/ZR/EX/RC/IG/OA/IA) from one Item Tax Template. + + Uses the Item Tax row's ``tax_category`` if it matches an FTA code; + otherwise defaults to ``SR`` (Standard Rated). Setting ``tax_category`` + on each Item Tax is the supported way to control this — there is no + heuristic fallback on the template name. + """ + if not item_tax_template: + return "SR" + + tax_category = frappe.db.get_value( + "Item Tax", + {"item_tax_template": item_tax_template}, + "tax_category", + ) + if tax_category and tax_category in _FTA_TAX_CODES: + return tax_category + return "SR" diff --git a/erpnext/regional/doctype/fta_audit_file/test_fta_audit_file.py b/erpnext/regional/doctype/fta_audit_file/test_fta_audit_file.py new file mode 100644 index 00000000000..b5f76558d24 --- /dev/null +++ b/erpnext/regional/doctype/fta_audit_file/test_fta_audit_file.py @@ -0,0 +1,258 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestFTAAuditFile(FrappeTestCase): + def setUp(self): + """Create a UAE test company with TRN before each test. + + Per-test creation (not setUpClass) because FrappeTestCase rolls + back the database after each test, including class-level fixtures. + """ + self.company = self._get_or_create_test_company() + + def _get_or_create_test_company(self): + company_name = "_Test Company UAE" + if not frappe.db.exists("Company", company_name): + frappe.get_doc( + { + "doctype": "Company", + "company_name": company_name, + "abbr": "_TCU", + "country": "United Arab Emirates", + "default_currency": "AED", + "tax_id": "100123456789012", + } + ).insert(ignore_permissions=True) + else: + company = frappe.get_doc("Company", company_name) + if not company.tax_id: + company.tax_id = "100123456789012" + company.save(ignore_permissions=True) + return company_name + + def test_fta_audit_file_creation(self): + """Test that FTA Audit File can be created.""" + doc = frappe.get_doc( + { + "doctype": "FTA Audit File", + "company": self.company, + "from_date": "2024-01-01", + "to_date": "2024-03-31", + "file_type": "VAT", + } + ) + doc.insert() + + self.assertTrue(doc.name) + self.assertEqual(doc.status, "Draft") + self.assertEqual(doc.file_type, "VAT") + + def test_date_validation(self): + """Test that from_date cannot be after to_date.""" + doc = frappe.get_doc( + { + "doctype": "FTA Audit File", + "company": self.company, + "from_date": "2024-03-31", + "to_date": "2024-01-01", + "file_type": "VAT", + } + ) + + self.assertRaises(frappe.ValidationError, doc.insert) + + def test_company_trn_validation(self): + """Test that company must have a TRN.""" + company_no_trn = "_Test Company No TRN" + + if not frappe.db.exists("Company", company_no_trn): + company = frappe.get_doc( + { + "doctype": "Company", + "company_name": company_no_trn, + "abbr": "_TCNT", + "country": "United Arab Emirates", + "default_currency": "AED", + } + ) + company.insert(ignore_permissions=True) + + doc = frappe.get_doc( + { + "doctype": "FTA Audit File", + "company": company_no_trn, + "from_date": "2024-01-01", + "to_date": "2024-03-31", + "file_type": "VAT", + } + ) + + self.assertRaises(frappe.ValidationError, doc.insert) + + def test_generate_faf_empty_period(self): + """End-to-end smoke test: generate against an empty period. + + With ``frappe.flags.in_test`` set by FrappeTestCase, the enqueued + job runs synchronously, so by the time generate_faf() returns the + doc has reached its terminal status. + """ + doc = frappe.get_doc( + { + "doctype": "FTA Audit File", + "company": self.company, + "from_date": "2099-01-01", + "to_date": "2099-01-31", + "file_type": "VAT", + } + ) + doc.insert() + + result = doc.generate_faf() + self.assertTrue(result["success"]) + + doc.reload() + self.assertEqual(doc.status, "Generated") + self.assertTrue(doc.faf_file) + + self.assertIn("Company Information written", doc.generation_log) + self.assertIn("Purchase Listing written", doc.generation_log) + self.assertIn("Supply Listing written", doc.generation_log) + self.assertIn("General Ledger written", doc.generation_log) + + def test_generate_faf_csv_structure(self): + """The generated CSV must contain the four spec section markers.""" + doc = frappe.get_doc( + { + "doctype": "FTA Audit File", + "company": self.company, + "from_date": "2099-02-01", + "to_date": "2099-02-28", + "file_type": "VAT", + } + ) + doc.insert() + doc.generate_faf() + doc.reload() + self.assertEqual(doc.status, "Generated") + + file_doc = frappe.get_doc("File", {"file_url": doc.faf_file}) + csv_content = file_doc.get_content() + if isinstance(csv_content, bytes): + csv_content = csv_content.decode("utf-8") + for marker in ( + "CompInfoStart", + "CompInfoEnd", + "PurcDataStart", + "PurcDataEnd", + "SuppDataStart", + "SuppDataEnd", + "GLDataStart", + "GLDataEnd", + ): + self.assertIn(marker, csv_content, f"Missing FAF section marker {marker!r}") + + self.assertIn("FAFv1.0.0", csv_content) + + def test_tax_agent_fields_appear_in_company_info(self): + """Tax Agency / Tax Agent details must round-trip into the CSV.""" + doc = frappe.get_doc( + { + "doctype": "FTA Audit File", + "company": self.company, + "from_date": "2099-04-01", + "to_date": "2099-04-30", + "file_type": "VAT", + "tax_agency_name": "Acme Tax Agency", + "tan": "TAN-555-001", + "tax_agent_name": "Jane Auditor", + "taan": "TAAN-777", + } + ) + doc.insert() + doc.generate_faf() + doc.reload() + self.assertEqual(doc.status, "Generated") + + file_doc = frappe.get_doc("File", {"file_url": doc.faf_file}) + csv_content = file_doc.get_content() + if isinstance(csv_content, bytes): + csv_content = csv_content.decode("utf-8") + + for value in ("Acme Tax Agency", "TAN-555-001", "Jane Auditor", "TAAN-777"): + self.assertIn(value, csv_content, f"Missing tax-agent value {value!r} in FAF") + + def test_decimal_fields_use_two_decimal_places(self): + """Decimal[14,2] cells must always emit two decimal places per spec.""" + doc = frappe.get_doc( + { + "doctype": "FTA Audit File", + "company": self.company, + "from_date": "2099-05-01", + "to_date": "2099-05-31", + "file_type": "VAT", + } + ) + doc.insert() + doc.generate_faf() + doc.reload() + + file_doc = frappe.get_doc("File", {"file_url": doc.faf_file}) + csv_content = file_doc.get_content() + if isinstance(csv_content, bytes): + csv_content = csv_content.decode("utf-8") + + self.assertIn("PurcDataEnd,0.00,0.00,0", csv_content) + self.assertIn("SuppDataEnd,0.00,0.00,0", csv_content) + self.assertIn("GLDataEnd,0.00,0.00,0,AED", csv_content) + + self.assertNotIn("PurcDataEnd,0.0,", csv_content) + self.assertNotIn("SuppDataEnd,0.0,", csv_content) + self.assertNotIn("GLDataEnd,0.0,", csv_content) + + def test_generate_faf_excise_not_yet_implemented(self): + """Excise FAF (Appendix 6) should error cleanly until implemented.""" + doc = frappe.get_doc( + { + "doctype": "FTA Audit File", + "company": self.company, + "from_date": "2099-03-01", + "to_date": "2099-03-31", + "file_type": "Excise", + } + ) + doc.insert() + + self.assertRaises(frappe.ValidationError, doc.generate_faf) + doc.reload() + self.assertEqual(doc.status, "Error") + + def test_mark_as_submitted_workflow(self): + """Generated docs can be marked submitted; non-Generated cannot.""" + doc = frappe.get_doc( + { + "doctype": "FTA Audit File", + "company": self.company, + "from_date": "2099-05-01", + "to_date": "2099-05-31", + "file_type": "VAT", + } + ) + doc.insert() + + self.assertRaises(frappe.ValidationError, doc.mark_as_submitted) + + doc.generate_faf() + doc.reload() + self.assertEqual(doc.status, "Generated") + + result = doc.mark_as_submitted() + self.assertTrue(result["success"]) + doc.reload() + self.assertEqual(doc.status, "Submitted") + + def tearDown(self): + frappe.db.rollback() diff --git a/erpnext/regional/report/uae_vat_201/uae_vat_201.html b/erpnext/regional/report/uae_vat_201/uae_vat_201.html index 7328f3f218e..b458e3d09ba 100644 --- a/erpnext/regional/report/uae_vat_201/uae_vat_201.html +++ b/erpnext/regional/report/uae_vat_201/uae_vat_201.html @@ -1,77 +1,104 @@ {% - var report_columns = report.get_columns_for_print(); - report_columns = report_columns.filter(col => !col.hidden); + var report_columns = report.get_columns_for_print(); + report_columns = report_columns.filter(col => !col.hidden); %} -

{%= __(report.report_name) %}

+

{%= __(report.report_name) %}

-

{%= __("VAT on Sales and All Other Outputs") %}

+

{%= __("VAT on Sales and All Other Outputs") %}

- - - - - {% for (let i=2; i{%= report_columns[i].label %} - {% } %} - - - - {% for (let j=1; j<12; j++) { %} - {% - var row = data[j]; - %} - - {% for (let i=0; i - {% const fieldname = report_columns[i].fieldname; %} - {% if (!is_null(row[fieldname])) { %} - {%= frappe.format(row[fieldname], report_columns[i], {}, row) %} - {% } %} - - {% } %} + + + + + + + + {% for (let j=1; j<13; j++) { %} + {% var row = data[j]; %} + {% if (row) { %} + + {% for (let i=0; i + {% const fieldname = report_columns[i].fieldname; %} + {% if (!is_null(row[fieldname])) { %} + {%= frappe.format(row[fieldname], report_columns[i], {}, row) %} + {% } %} + + {% } %} + + {% } %} {% } %}
{%= report_columns[0].label %}{%= report_columns[1].label %}
{%= report_columns[0].label %}{%= report_columns[1].label %}{%= report_columns[2].label %}{%= report_columns[3].label %}
-

{%= __("VAT on Expenses and All Other Inputs") %}

+

{%= __("VAT on Expenses and All Other Inputs") %}

- +
- - - - {% for (let i=2; i{%= report_columns[i].label %} - {% } %} - - - - {% for (let j=14; j - {% for (let i=0; i - {% const fieldname = report_columns[i].fieldname; %} - {% if (!is_null(row[fieldname])) { %} - {%= frappe.format(row[fieldname], report_columns[i], {}, row) %} - {% } %} - - {% } %} + + + + + + + + {% for (let j=15; j<18; j++) { %} + {% var row = data[j]; %} + {% if (row) { %} + + {% for (let i=0; i + {% const fieldname = report_columns[i].fieldname; %} + {% if (!is_null(row[fieldname])) { %} + {%= frappe.format(row[fieldname], report_columns[i], {}, row) %} + {% } %} + + {% } %} + + {% } %} + {% } %} + +
{%= report_columns[0].label %}{%= report_columns[1].label %}
{%= report_columns[0].label %}{%= report_columns[1].label %}{%= report_columns[2].label %}{%= report_columns[3].label %}
+ +

{%= __("Net VAT Due") %}

+ + + + + + + + + + + {% for (let j=20; j<23; j++) { %} + {% var row = data[j]; %} + {% if (row) { %} + + + + + + {% } %} {% } %} -
{%= report_columns[0].label %}{%= report_columns[1].label %}{%= report_columns[3].label %}
{%= row.no %}{%= row.legend %}{%= row.vat_amount %}
diff --git a/erpnext/regional/report/uae_vat_201/uae_vat_201.js b/erpnext/regional/report/uae_vat_201/uae_vat_201.js index e62d3395f20..21c8cba72ac 100644 --- a/erpnext/regional/report/uae_vat_201/uae_vat_201.js +++ b/erpnext/regional/report/uae_vat_201/uae_vat_201.js @@ -33,11 +33,14 @@ frappe.query_reports["UAE VAT 201"] = { default: frappe.datetime.get_today(), }, ], + formatter: function (value, row, column, data, default_formatter) { if ( data && (data.legend == "VAT on Sales and All Other Outputs" || - data.legend == "VAT on Expenses and All Other Inputs") && + data.legend == "VAT on Expenses and All Other Inputs" || + data.legend == "Net VAT Due" || + data.legend == "Total") && data.legend == value ) { value = $(`${value}`); diff --git a/erpnext/regional/report/uae_vat_201/uae_vat_201.py b/erpnext/regional/report/uae_vat_201/uae_vat_201.py index aaca8f01654..6dc18501af7 100644 --- a/erpnext/regional/report/uae_vat_201/uae_vat_201.py +++ b/erpnext/regional/report/uae_vat_201/uae_vat_201.py @@ -8,11 +8,27 @@ from frappe.query_builder.functions import Sum from erpnext import get_region +# Per-execution memoization cache for the helper functions below. +# Cleared at the start of every execute() call so each report run gets +# fresh data; within a single run, repeated calls reuse the result. +_cache = {} + + +def _cached(fn): + def wrapper(filters, *args, **kwargs): + key = (fn.__name__, tuple(sorted((filters or {}).items()))) + if key not in _cache: + _cache[key] = fn(filters, *args, **kwargs) + return _cache[key] + + return wrapper + def execute(filters=None): validate_company_region(filters) + _cache.clear() columns = get_columns() - data, emirates, amounts_by_emirate = get_data(filters) + data = get_data(filters) return columns, data @@ -48,23 +64,198 @@ def get_columns(): def get_data(filters=None): """Returns the list of dictionaries. Each dictionary is a row in the datatable and chart data.""" data = [] - emirates, amounts_by_emirate = append_vat_on_sales(data, filters) + amounts_by_emirate = append_vat_on_sales(data, filters) append_vat_on_expenses(data, filters) - return data, emirates, amounts_by_emirate + net_vat_due(data, filters, amounts_by_emirate) + + final_data = [] + for i in range(0, len(data)): + if data[i].get("legend") == "Standard rated supplies in Abu Dhabi": + legend_link = f""" + {data[i].get("legend")} + """ + final_data.append( + { + "no": data[i].get("no"), + "legend": legend_link, + "amount": data[i].get("amount"), + "vat_amount": data[i].get("vat_amount"), + } + ) + elif data[i].get("legend") == "Standard rated supplies in Dubai": + company = frappe.defaults.get_user_default("Company") + company_filters = [ + ["Dynamic Link", "link_doctype", "=", "Company"], + ["Dynamic Link", "link_name", "=", company], + ["Address", "is_your_company_address", "=", 1], + ] + company_fields = [ + "name", + "address_line1", + "address_line2", + "city", + "state", + "country", + "emirate", + ] + address = frappe.get_all("Address", filters=company_filters, fields=company_fields) + + if address: + if address[0].get("emirate"): + name = "Standard rated supplies in" + " " + address[0].get("emirate") + else: + name = "Standard rated supplies in Dubai" + else: + name = "Standard rated supplies in Dubai" + + legend_link = f""" + {name} + """ + final_data.append( + { + "no": data[i].get("no"), + "legend": legend_link, + "amount": data[i].get("amount"), + "vat_amount": data[i].get("vat_amount"), + } + ) + + elif data[i].get("legend") == "Standard rated supplies in Sharjah": + legend_link = f""" + {data[i].get("legend")} + """ + final_data.append( + { + "no": data[i].get("no"), + "legend": legend_link, + "amount": data[i].get("amount"), + "vat_amount": data[i].get("vat_amount"), + } + ) + elif data[i].get("legend") == "Standard rated supplies in Ajman": + legend_link = f""" + {data[i].get("legend")} + """ + final_data.append( + { + "no": data[i].get("no"), + "legend": legend_link, + "amount": data[i].get("amount"), + "vat_amount": data[i].get("vat_amount"), + } + ) + elif data[i].get("legend") == "Standard rated supplies in Umm Al Quwain": + legend_link = f""" + {data[i].get("legend")} + """ + final_data.append( + { + "no": data[i].get("no"), + "legend": legend_link, + "amount": data[i].get("amount"), + "vat_amount": data[i].get("vat_amount"), + } + ) + elif data[i].get("legend") == "Standard rated supplies in Ras Al Khaimah": + legend_link = f""" + {data[i].get("legend")} + """ + final_data.append( + { + "no": data[i].get("no"), + "legend": legend_link, + "amount": data[i].get("amount"), + "vat_amount": data[i].get("vat_amount"), + } + ) + elif data[i].get("legend") == "Standard rated supplies in Fujairah": + legend_link = f""" + {data[i].get("legend")} + """ + final_data.append( + { + "no": data[i].get("no"), + "legend": legend_link, + "amount": data[i].get("amount"), + "vat_amount": data[i].get("vat_amount"), + } + ) + elif data[i].get("legend") == "Supplies subject to the reverse charge provision": + legend_link = f""" + {data[i].get("legend")} + """ + final_data.append( + { + "no": data[i].get("no"), + "legend": legend_link, + "amount": data[i].get("amount"), + "vat_amount": data[i].get("vat_amount"), + } + ) + elif data[i].get("legend") == "Zero Rated": + legend_link = f""" + {data[i].get("legend")} + """ + final_data.append( + { + "no": data[i].get("no"), + "legend": legend_link, + "amount": data[i].get("amount"), + "vat_amount": data[i].get("vat_amount"), + } + ) + elif data[i].get("legend") == "Exempt Supplies": + legend_link = f""" + {data[i].get("legend")} + """ + final_data.append( + { + "no": data[i].get("no"), + "legend": legend_link, + "amount": data[i].get("amount"), + "vat_amount": data[i].get("vat_amount"), + } + ) + elif data[i].get("legend") == "Standard Rated Expenses": + legend_link = f""" + {data[i].get("legend")} + """ + final_data.append( + { + "no": data[i].get("no"), + "legend": legend_link, + "amount": data[i].get("amount"), + "vat_amount": data[i].get("vat_amount"), + } + ) + else: + final_data.append( + { + "no": data[i].get("no"), + "legend": data[i].get("legend"), + "amount": data[i].get("amount"), + "vat_amount": data[i].get("vat_amount"), + } + ) + + return final_data def append_vat_on_sales(data, filters): """Appends Sales and All Other Outputs.""" append_data(data, "", _("VAT on Sales and All Other Outputs"), "", "") - emirates, amounts_by_emirate = standard_rated_expenses_emiratewise(data, filters) + amounts_by_emirate = standard_rated_expenses_emiratewise(data, filters) + + si_amount = amounts_by_emirate[1] + si_vat = amounts_by_emirate[2] append_data( data, "2", _("Tax Refunds provided to Tourists under the Tax Refunds for Tourists Scheme"), - frappe.format((-1) * get_tourist_tax_return_total(filters), "Currency"), - frappe.format((-1) * get_tourist_tax_return_tax(filters), "Currency"), + format_currency_signed((-1) * get_tourist_tax_return_total(filters)), + format_currency_signed((-1) * get_tourist_tax_return_tax(filters)), ) append_data( @@ -79,9 +270,27 @@ def append_vat_on_sales(data, filters): append_data(data, "5", _("Exempt Supplies"), frappe.format(get_exempt_total(filters), "Currency"), "-") + append_data( + data, + "8", + _("Total"), + frappe.format( + (-1) * get_tourist_tax_return_total(filters) + + get_reverse_charge_total(filters) + + get_zero_rated_total(filters) + + get_exempt_total(filters) + + sum(si_amount), + "Currency", + ), + frappe.format( + (-1) * get_tourist_tax_return_tax(filters) + get_reverse_charge_tax(filters) + sum(si_vat), + "Currency", + ), + ) + append_data(data, "", "", "", "") - return emirates, amounts_by_emirate + return amounts_by_emirate def standard_rated_expenses_emiratewise(data, filters): @@ -98,16 +307,21 @@ def standard_rated_expenses_emiratewise(data, filters): "vat_amount": frappe.format(vat, "Currency"), } amounts_by_emirate = append_emiratewise_expenses(data, emirates, amounts_by_emirate) - return emirates, amounts_by_emirate + return amounts_by_emirate def append_emiratewise_expenses(data, emirates, amounts_by_emirate): """Append emiratewise standard rated expenses and vat.""" + s_amount = [] + v_amount = [] for no, emirate in enumerate(emirates, 97): if emirate in amounts_by_emirate: amounts_by_emirate[emirate]["no"] = _("1{0}").format(chr(no)) amounts_by_emirate[emirate]["legend"] = _("Standard rated supplies in {0}").format(emirate) data.append(amounts_by_emirate[emirate]) + + s_amount.append(amounts_by_emirate[emirate].get("raw_amount") or 0) + v_amount.append(amounts_by_emirate[emirate].get("raw_vat_amount") or 0) else: append_data( data, @@ -116,7 +330,7 @@ def append_emiratewise_expenses(data, emirates, amounts_by_emirate): frappe.format(0, "Currency"), frappe.format(0, "Currency"), ) - return amounts_by_emirate + return amounts_by_emirate, s_amount, v_amount def append_vat_on_expenses(data, filters): @@ -137,12 +351,77 @@ def append_vat_on_expenses(data, filters): frappe.format(get_reverse_charge_recoverable_tax(filters), "Currency"), ) + append_data( + data, + "11", + _("Total"), + frappe.format( + get_standard_rated_expenses_total(filters) + get_reverse_charge_recoverable_total(filters), + "Currency", + ), + frappe.format( + get_standard_rated_expenses_tax(filters) + get_reverse_charge_recoverable_tax(filters), + "Currency", + ), + ) + + +def net_vat_due(data, filters, amounts_by_emirate): + si_vat = amounts_by_emirate[2] + + append_data(data, "", "", "", "") + append_data(data, "", _("Net VAT Due"), "", "") + append_data( + data, + "12", + _("Total value of due tax for the period"), + frappe.format(0.00, "Currency"), + frappe.format( + sum(si_vat) + (-1) * get_tourist_tax_return_tax(filters) + get_reverse_charge_tax(filters), + "Currency", + ), + ) + append_data( + data, + "13", + _("Total value of recoverable tax for the period "), + frappe.format(0.00, "Currency"), + frappe.format( + get_standard_rated_expenses_tax(filters) + get_reverse_charge_recoverable_tax(filters), + "Currency", + ), + ) + + # Calculate payable tax: Due Tax - Recoverable Tax + due_tax = sum(si_vat) + (-1) * get_tourist_tax_return_tax(filters) + get_reverse_charge_tax(filters) + recoverable_tax = get_standard_rated_expenses_tax(filters) + get_reverse_charge_recoverable_tax(filters) + payable_tax = due_tax - recoverable_tax + + append_data( + data, + "14", + _("Payable tax for the period"), + frappe.format(0.00, "Currency"), + frappe.format(payable_tax, "Currency"), + ) + def append_data(data, no, legend, amount, vat_amount): """Returns data with appended value.""" data.append({"no": no, "legend": legend, "amount": amount, "vat_amount": vat_amount}) +def format_currency_signed(value): + """Format a number as currency, placing the minus sign *before* the currency symbol + when negative (e.g. "-د.إ 5,000.00" rather than "د.إ -5,000.00").""" + if value is None: + value = 0 + if value < 0: + return "-" + frappe.format(abs(value), "Currency") + return frappe.format(value, "Currency") + + +@_cached def get_total_emiratewise(filters): """Returns Emiratewise Amount and Taxes.""" i = frappe.qb.DocType("Sales Invoice Item") @@ -175,11 +454,12 @@ def get_filters(filters): query_filters.append(["company", "=", filters["company"]]) if filters.get("from_date"): query_filters.append(["posting_date", ">=", filters["from_date"]]) - if filters.get("from_date"): + if filters.get("to_date"): query_filters.append(["posting_date", "<=", filters["to_date"]]) return query_filters +@_cached def get_reverse_charge_total(filters): """Returns the sum of the total of each Purchase invoice made.""" query_filters = get_filters(filters) @@ -190,7 +470,7 @@ def get_reverse_charge_total(filters): frappe.db.get_all( "Purchase Invoice", filters=query_filters, - fields=[{"SUM": "base_total"}], + fields=["sum(base_net_total)"], as_list=True, limit=1, )[0][0] @@ -200,6 +480,7 @@ def get_reverse_charge_total(filters): return 0 +@_cached def get_reverse_charge_tax(filters): """Returns the sum of the tax of each Purchase invoice made.""" p = frappe.qb.DocType("Purchase Invoice") @@ -226,6 +507,7 @@ def get_reverse_charge_tax(filters): return query.run()[0][0] or 0 +@_cached def get_reverse_charge_recoverable_total(filters): """Returns the sum of the total of each Purchase invoice made with recoverable reverse charge.""" query_filters = get_filters(filters) @@ -237,7 +519,7 @@ def get_reverse_charge_recoverable_total(filters): frappe.db.get_all( "Purchase Invoice", filters=query_filters, - fields=[{"SUM": "base_total"}], + fields=["sum(base_net_total)"], as_list=True, limit=1, )[0][0] @@ -247,6 +529,7 @@ def get_reverse_charge_recoverable_total(filters): return 0 +@_cached def get_reverse_charge_recoverable_tax(filters): """Returns the sum of the tax of each Purchase invoice made.""" p = frappe.qb.DocType("Purchase Invoice") @@ -286,6 +569,7 @@ def get_conditions_join(filters, p): return conditions +@_cached def get_standard_rated_expenses_total(filters): """Returns the sum of the total of each Purchase invoice made with recoverable reverse charge.""" query_filters = get_filters(filters) @@ -296,7 +580,7 @@ def get_standard_rated_expenses_total(filters): frappe.db.get_all( "Purchase Invoice", filters=query_filters, - fields=[{"SUM": "base_total"}], + fields=["sum(base_net_total)"], as_list=True, limit=1, )[0][0] @@ -306,6 +590,7 @@ def get_standard_rated_expenses_total(filters): return 0 +@_cached def get_standard_rated_expenses_tax(filters): """Returns the sum of the tax of each Purchase invoice made.""" query_filters = get_filters(filters) @@ -316,7 +601,7 @@ def get_standard_rated_expenses_tax(filters): frappe.db.get_all( "Purchase Invoice", filters=query_filters, - fields=[{"SUM": "recoverable_standard_rated_expenses"}], + fields=["sum(recoverable_standard_rated_expenses)"], as_list=True, limit=1, )[0][0] @@ -326,6 +611,7 @@ def get_standard_rated_expenses_tax(filters): return 0 +@_cached def get_tourist_tax_return_total(filters): """Returns the sum of the total of each Sales invoice with non zero tourist_tax_return.""" query_filters = get_filters(filters) @@ -334,7 +620,7 @@ def get_tourist_tax_return_total(filters): try: return ( frappe.db.get_all( - "Sales Invoice", filters=query_filters, fields=[{"SUM": "base_total"}], as_list=True, limit=1 + "Sales Invoice", filters=query_filters, fields=["sum(base_net_total)"], as_list=True, limit=1 )[0][0] or 0 ) @@ -342,6 +628,7 @@ def get_tourist_tax_return_total(filters): return 0 +@_cached def get_tourist_tax_return_tax(filters): """Returns the sum of the tax of each Sales invoice with non zero tourist_tax_return.""" query_filters = get_filters(filters) @@ -352,7 +639,7 @@ def get_tourist_tax_return_tax(filters): frappe.db.get_all( "Sales Invoice", filters=query_filters, - fields=[{"SUM": "tourist_tax_return"}], + fields=["sum(tourist_tax_return)"], as_list=True, limit=1, )[0][0] @@ -362,6 +649,7 @@ def get_tourist_tax_return_tax(filters): return 0 +@_cached def get_zero_rated_total(filters): """Returns the sum of each Sales Invoice Item Amount which is zero rated.""" i = frappe.qb.DocType("Sales Invoice Item") @@ -381,6 +669,7 @@ def get_zero_rated_total(filters): return 0 +@_cached def get_exempt_total(filters): """Returns the sum of each Sales Invoice Item Amount which is Vat Exempt.""" i = frappe.qb.DocType("Sales Invoice Item") diff --git a/erpnext/regional/report/uae_vat_register/__init__.py b/erpnext/regional/report/uae_vat_register/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/regional/report/uae_vat_register/uae_vat_register.js b/erpnext/regional/report/uae_vat_register/uae_vat_register.js new file mode 100644 index 00000000000..232aa7df691 --- /dev/null +++ b/erpnext/regional/report/uae_vat_register/uae_vat_register.js @@ -0,0 +1,73 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.query_reports["UAE VAT Register"] = { + filters: [ + { + fieldname: "company", + label: __("Company"), + fieldtype: "Link", + options: "Company", + reqd: 1, + default: frappe.defaults.get_user_default("Company"), + }, + { + fieldname: "from_date", + label: __("From Date"), + fieldtype: "Date", + reqd: 1, + default: frappe.datetime.add_months(frappe.datetime.get_today(), -3), + }, + { + fieldname: "to_date", + label: __("To Date"), + fieldtype: "Date", + reqd: 1, + default: frappe.datetime.get_today(), + }, + { + fieldname: "doc_type", + label: __("Document Type"), + fieldtype: "Select", + options: ["Sales Invoice", "Purchase Invoice"], + default: "Sales Invoice", + reqd: 1, + }, + { + fieldname: "category", + label: __("Category"), + fieldtype: "Select", + options: ["", "Standard", "Zero Rated", "Exempt Rated"], + depends_on: "eval: doc.doc_type == 'Sales Invoice'", + }, + { + fieldname: "vat", + label: __("Emirate"), + fieldtype: "Select", + options: [ + "", + "Abu Dhabi", + "Dubai", + "Sharjah", + "Ajman", + "Umm Al Quwain", + "Ras Al Khaimah", + "Fujairah", + ], + depends_on: "eval: doc.doc_type == 'Sales Invoice'", + }, + { + fieldname: "reverse_charge", + label: __("Reverse Charge"), + fieldtype: "Select", + options: ["", "Y", "N"], + depends_on: "eval: doc.doc_type == 'Purchase Invoice'", + }, + { + fieldname: "item_wise", + label: __("Item-wise"), + fieldtype: "Check", + default: 0, + }, + ], +}; diff --git a/erpnext/regional/report/uae_vat_register/uae_vat_register.json b/erpnext/regional/report/uae_vat_register/uae_vat_register.json new file mode 100644 index 00000000000..95c38c145e3 --- /dev/null +++ b/erpnext/regional/report/uae_vat_register/uae_vat_register.json @@ -0,0 +1,24 @@ +{ + "add_total_row": 1, + "add_translate_data": 0, + "columns": [], + "creation": "2025-12-11 00:29:21.891860", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letterhead": null, + "modified": "2025-12-11 00:29:21.891860", + "modified_by": "Administrator", + "module": "Regional", + "name": "UAE VAT Register", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "GL Entry", + "report_name": "UAE VAT Register", + "report_type": "Script Report", + "roles": [], + "timeout": 0 +} \ No newline at end of file diff --git a/erpnext/regional/report/uae_vat_register/uae_vat_register.py b/erpnext/regional/report/uae_vat_register/uae_vat_register.py new file mode 100644 index 00000000000..04427f7359a --- /dev/null +++ b/erpnext/regional/report/uae_vat_register/uae_vat_register.py @@ -0,0 +1,216 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + + +import frappe +from frappe import _ + + +def execute(filters=None): + if not filters: + filters = {} + columns = get_columns(filters) + data = get_data(filters) + return columns, data + + +def get_columns(filters): + doc_type = filters.get("doc_type") or "Sales Invoice" + is_sales = doc_type == "Sales Invoice" + item_wise = bool(filters.get("item_wise")) + + columns = [ + { + "label": _("Invoice"), + "fieldname": "name", + "fieldtype": "Link", + "options": doc_type, + "width": 180, + }, + {"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 100}, + { + "label": _("Customer") if is_sales else _("Supplier"), + "fieldname": "party", + "fieldtype": "Link", + "options": "Customer" if is_sales else "Supplier", + "width": 150, + }, + { + "label": _("Cost Center"), + "fieldname": "cost_center", + "fieldtype": "Link", + "options": "Cost Center", + "width": 130, + }, + ] + if is_sales: + columns.append({"label": _("Emirate"), "fieldname": "emirate", "fieldtype": "Data", "width": 110}) + else: + columns.append( + { + "label": _("Reverse Charge"), + "fieldname": "reverse_charge", + "fieldtype": "Data", + "width": 110, + } + ) + if item_wise: + columns.extend( + [ + { + "label": _("Item Code"), + "fieldname": "item_code", + "fieldtype": "Link", + "options": "Item", + "width": 150, + }, + {"label": _("Qty"), "fieldname": "qty", "fieldtype": "Float", "width": 80}, + {"label": _("Rate"), "fieldname": "rate", "fieldtype": "Currency", "width": 100}, + ] + ) + else: + columns.append({"label": _("Qty"), "fieldname": "qty", "fieldtype": "Float", "width": 80}) + columns.extend( + [ + {"label": _("Net Amount"), "fieldname": "net_amount", "fieldtype": "Currency", "width": 120}, + {"label": _("VAT Amount"), "fieldname": "vat_amount", "fieldtype": "Currency", "width": 120}, + { + "label": _("Total Amount"), + "fieldname": "total_amount", + "fieldtype": "Currency", + "width": 120, + }, + ] + ) + return columns + + +def get_data(filters): + doc_type = filters.get("doc_type") or "Sales Invoice" + if doc_type == "Sales Invoice": + return fetch_sales_rows(filters) + if doc_type == "Purchase Invoice": + return fetch_purchase_rows(filters) + return [] + + +def fetch_sales_rows(filters): + conditions, params = build_conditions(filters) + category_clause = sales_category_clause(filters.get("category")) + + emirate_clause = "" + if filters.get("vat"): + emirate_clause = "AND s.vat_emirate = %(vat)s" + params["vat"] = filters["vat"] + + if filters.get("item_wise"): + return frappe.db.sql( + f""" + SELECT + s.name, s.posting_date, s.customer AS party, + COALESCE(i.cost_center, s.cost_center) AS cost_center, + s.vat_emirate AS emirate, + i.item_code, i.qty, i.rate, + i.base_net_amount AS net_amount, + i.tax_amount AS vat_amount, + (i.base_net_amount + COALESCE(i.tax_amount, 0)) AS total_amount + FROM `tabSales Invoice` s + INNER JOIN `tabSales Invoice Item` i ON i.parent = s.name + WHERE s.docstatus = 1 {conditions} {category_clause} {emirate_clause} + ORDER BY s.posting_date, s.name, i.idx + """, + params, + as_dict=True, + ) + + return frappe.db.sql( + f""" + SELECT + s.name, s.posting_date, s.customer AS party, s.cost_center, + s.vat_emirate AS emirate, + SUM(i.qty) AS qty, + SUM(i.base_net_amount) AS net_amount, + SUM(i.tax_amount) AS vat_amount, + SUM(i.base_net_amount + COALESCE(i.tax_amount, 0)) AS total_amount + FROM `tabSales Invoice` s + INNER JOIN `tabSales Invoice Item` i ON i.parent = s.name + WHERE s.docstatus = 1 {conditions} {category_clause} {emirate_clause} + GROUP BY s.name, s.posting_date, s.customer, s.cost_center, s.vat_emirate + ORDER BY s.posting_date, s.name + """, + params, + as_dict=True, + ) + + +def fetch_purchase_rows(filters): + conditions, params = build_conditions(filters) + + rc_clause = "" + if filters.get("reverse_charge") in ("Y", "N"): + rc_clause = "AND s.reverse_charge = %(reverse_charge)s" + params["reverse_charge"] = filters["reverse_charge"] + + if filters.get("item_wise"): + return frappe.db.sql( + f""" + SELECT + s.name, s.posting_date, s.supplier AS party, + COALESCE(i.cost_center, s.cost_center) AS cost_center, + s.reverse_charge, + i.item_code, i.qty, i.rate, + i.base_net_amount AS net_amount, + i.tax_amount AS vat_amount, + (i.base_net_amount + COALESCE(i.tax_amount, 0)) AS total_amount + FROM `tabPurchase Invoice` s + INNER JOIN `tabPurchase Invoice Item` i ON i.parent = s.name + WHERE s.docstatus = 1 {conditions} {rc_clause} + ORDER BY s.posting_date, s.name, i.idx + """, + params, + as_dict=True, + ) + + return frappe.db.sql( + f""" + SELECT + s.name, s.posting_date, s.supplier AS party, s.cost_center, + s.reverse_charge, + SUM(i.qty) AS qty, + SUM(i.base_net_amount) AS net_amount, + SUM(i.tax_amount) AS vat_amount, + SUM(i.base_net_amount + COALESCE(i.tax_amount, 0)) AS total_amount + FROM `tabPurchase Invoice` s + INNER JOIN `tabPurchase Invoice Item` i ON i.parent = s.name + WHERE s.docstatus = 1 {conditions} {rc_clause} + GROUP BY s.name, s.posting_date, s.supplier, s.cost_center, s.reverse_charge + ORDER BY s.posting_date, s.name + """, + params, + as_dict=True, + ) + + +def build_conditions(filters): + conditions = "" + params = {} + if filters.get("company"): + conditions += " AND s.company = %(company)s" + params["company"] = filters["company"] + if filters.get("from_date"): + conditions += " AND s.posting_date >= %(from_date)s" + params["from_date"] = filters["from_date"] + if filters.get("to_date"): + conditions += " AND s.posting_date <= %(to_date)s" + params["to_date"] = filters["to_date"] + return conditions, params + + +def sales_category_clause(category): + if category == "Standard": + return "AND i.is_zero_rated != 1 AND i.is_exempt != 1" + if category == "Zero Rated": + return "AND i.is_zero_rated = 1" + if category == "Exempt Rated": + return "AND i.is_exempt = 1" + return "" diff --git a/erpnext/regional/united_arab_emirates/setup.py b/erpnext/regional/united_arab_emirates/setup.py index 2a26af226fa..69a442619ce 100644 --- a/erpnext/regional/united_arab_emirates/setup.py +++ b/erpnext/regional/united_arab_emirates/setup.py @@ -200,6 +200,14 @@ def make_custom_fields(): print_hide=1, ), ], + "Company": [ + dict( + fieldname="company_name_in_arabic", + label="Company Name in Arabic", + fieldtype="Data", + insert_after="company_name", + ), + ], "Customer": [ dict( fieldname="customer_name_in_arabic", From 806f30fa87c0c8040aaa37d2c7d362e9aa7bcac3 Mon Sep 17 00:00:00 2001 From: Bibin <17405044+bibinqcs@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:20:06 +0000 Subject: [PATCH 2/7] refactor: FTA Audit File and UAE VAT Reports --- .../v16_0/add_arabic_company_name_field.py | 19 +- .../fta_audit_file/fta_audit_file.json | 2 +- .../doctype/fta_audit_file/fta_audit_file.py | 145 ++++- .../report/uae_vat_201/uae_vat_201.py | 602 +++++++----------- .../uae_vat_register/uae_vat_register.py | 197 +++--- 5 files changed, 445 insertions(+), 520 deletions(-) diff --git a/erpnext/patches/v16_0/add_arabic_company_name_field.py b/erpnext/patches/v16_0/add_arabic_company_name_field.py index 57b8db59f97..100bf5502a4 100644 --- a/erpnext/patches/v16_0/add_arabic_company_name_field.py +++ b/erpnext/patches/v16_0/add_arabic_company_name_field.py @@ -1,10 +1,21 @@ import frappe - -from erpnext.regional.united_arab_emirates.setup import make_custom_fields +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields def execute(): - if not frappe.db.get_value("Company", {"country": "United Arab Emirates"}): + if not frappe.db.exists("Company", {"country": "United Arab Emirates"}): return - make_custom_fields() + create_custom_fields( + { + "Company": [ + { + "fieldname": "company_name_in_arabic", + "label": "Company Name in Arabic", + "fieldtype": "Data", + "insert_after": "company_name", + } + ] + }, + ignore_validate=True, + ) diff --git a/erpnext/regional/doctype/fta_audit_file/fta_audit_file.json b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.json index db64126ae59..061b50e219f 100644 --- a/erpnext/regional/doctype/fta_audit_file/fta_audit_file.json +++ b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.json @@ -112,7 +112,7 @@ "fieldname": "file_type", "fieldtype": "Select", "label": "File Type", - "options": "VAT\nExcise", + "options": "VAT", "default": "VAT", "reqd": 1 }, diff --git a/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py index fd8d937735e..1356c76a744 100644 --- a/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py +++ b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py @@ -35,6 +35,23 @@ DEFAULT_COUNTRY = "United Arab Emirates" DEFAULT_DATE = "31-12-9999" PRODUCT_VERSION = "ERPNext" +# Inputs that define what the FAF file represents. Once the file has been +# Generated or Submitted, changing any of these would silently desync the +# attached CSV from the form, so we lock them. +LOCKED_INPUT_FIELDS = ( + "company", + "from_date", + "to_date", + "file_type", + "include_opening_balance", + "tax_agency_name", + "tan", + "tax_agent_name", + "taan", +) +LOCKED_STATUSES = ("Generated", "Submitted") +IN_FLIGHT_STATUSES = ("Queued", "Generating") + class FTAAuditFile(Document): def validate(self): @@ -48,9 +65,31 @@ class FTAAuditFile(Document): ) ) + self._guard_locked_fields() + if self.status != "Error": self.error_message = None + def _guard_locked_fields(self): + """Block edits to FAF inputs once the file is Generated or Submitted. + + The status field alone is read-only in the UI, but a user with write + permission can still patch fields via REST or scripts; this enforces + immutability server-side so the attached CSV always matches the form. + """ + if self.is_new(): + return + + previous_status = self.get_db_value("status") + if previous_status not in LOCKED_STATUSES: + return + + changed = [f for f in LOCKED_INPUT_FIELDS if self.has_value_changed(f)] + if changed: + frappe.throw( + _("Cannot modify {0} after the FAF has been {1}.").format(", ".join(changed), previous_status) + ) + @frappe.whitelist() def generate_faf(self): """Queue FAF generation as a background job. @@ -64,6 +103,14 @@ class FTAAuditFile(Document): Under ``frappe.flags.in_test`` the job runs synchronously so tests can assert on the post-generation state without polling. """ + # Re-read status from DB so two concurrent button clicks can't both + # enqueue a job — the second one sees Queued/Generating and bails. + current_status = frappe.db.get_value(self.doctype, self.name, "status", for_update=True) + if current_status in IN_FLIGHT_STATUSES: + frappe.throw(_("FAF generation is already {0} for this document.").format(current_status)) + if current_status == "Submitted": + frappe.throw(_("Cannot regenerate a Submitted FAF.")) + self.status = "Queued" self.generation_log = "" self.error_message = None @@ -140,9 +187,6 @@ class FTAAuditFile(Document): log(f"Period: {self.from_date} to {self.to_date}") log(f"File Type: {self.file_type}") - if self.file_type != "VAT": - frappe.throw(_("FAF generation for {0} is not yet implemented").format(self.file_type)) - output = io.StringIO() writer = csv.writer(output) @@ -252,11 +296,14 @@ class FTAAuditFile(Document): "item_tax_template", ], ) - tax_code_map = { - t: _resolve_tax_code(t) - for t in {item.item_tax_template for items in items_by_invoice.values() for item in items} - if t - } + tax_code_bands = _bulk_tax_code_bands( + { + item.item_tax_template + for items in items_by_invoice.values() + for item in items + if item.item_tax_template + } + ) company_currency = _company_currency(self.company) @@ -285,7 +332,7 @@ class FTAAuditFile(Document): _clean(item.description or item.item_name or ""), _money(net_aed), _money(vat_aed), - tax_code_map.get(item.item_tax_template, "SR"), + _resolve_tax_code(item.item_tax_template, inv.posting_date, tax_code_bands), fcy_code, _money(net_fcy), _money(vat_fcy), @@ -351,11 +398,14 @@ class FTAAuditFile(Document): "is_exempt", ], ) - tax_code_map = { - t: _resolve_tax_code(t) - for t in {item.item_tax_template for items in items_by_invoice.values() for item in items} - if t - } + tax_code_bands = _bulk_tax_code_bands( + { + item.item_tax_template + for items in items_by_invoice.values() + for item in items + if item.item_tax_template + } + ) company_currency = _company_currency(self.company) @@ -379,7 +429,7 @@ class FTAAuditFile(Document): elif item.is_exempt: tax_code = "EX" else: - tax_code = tax_code_map.get(item.item_tax_template, "SR") + tax_code = _resolve_tax_code(item.item_tax_template, inv.posting_date, tax_code_bands) writer.writerow( [ @@ -563,7 +613,14 @@ def _bulk_party_field(doctype, names, field): def _bulk_party_country(party_doctype, party_names): - """Return ``{party_name: country}`` taken from each party's first address with a country.""" + """Return ``{party_name: country}`` for each party. + + Picks deterministically when a party has multiple addresses: prefer the + one flagged ``is_primary_address``, then ``is_shipping_address``, then + the lowest address name. Without this ordering, MariaDB would return + rows in storage-engine order and the FAF would be non-reproducible + across runs. + """ if not party_names: return {} @@ -579,6 +636,9 @@ def _bulk_party_country(party_doctype, party_names): .where(addr.country.isnotnull()) .where(addr.country != "") .select(dl.link_name, addr.country) + .orderby(addr.is_primary_address, order=frappe.qb.desc) + .orderby(addr.is_shipping_address, order=frappe.qb.desc) + .orderby(addr.name) .run(as_dict=True) ) out = {} @@ -638,22 +698,51 @@ def _bulk_invoice_items(child_doctype, invoice_names, fields): _FTA_TAX_CODES = ("SR", "ZR", "EX", "RC", "IG", "OA", "IA") -def _resolve_tax_code(item_tax_template): +def _bulk_tax_code_bands(item_tax_templates): + """Return ``{template: [(valid_from, tax_category), ...]}`` sorted desc by valid_from. + + One ``Item Tax Template`` can have multiple ``Item Tax`` rows with + different ``valid_from`` dates (e.g. tax code changing on a regulator + cutover). Fetching them all up-front lets ``_resolve_tax_code`` pick + the row that was in force on each invoice's posting date without an + extra DB hit per line item. + """ + if not item_tax_templates: + return {} + rows = frappe.get_all( + "Item Tax", + filters={"item_tax_template": ["in", list(item_tax_templates)]}, + fields=["item_tax_template", "tax_category", "valid_from"], + order_by="valid_from desc", + ) + out = {} + for r in rows: + out.setdefault(r["item_tax_template"], []).append((r.get("valid_from"), r.get("tax_category"))) + return out + + +def _resolve_tax_code(item_tax_template, posting_date, bands_map): """Derive FTA tax code (SR/ZR/EX/RC/IG/OA/IA) from one Item Tax Template. - Uses the Item Tax row's ``tax_category`` if it matches an FTA code; - otherwise defaults to ``SR`` (Standard Rated). Setting ``tax_category`` - on each Item Tax is the supported way to control this — there is no - heuristic fallback on the template name. + Picks the Item Tax row whose ``valid_from`` is the most recent value + that is still on or before ``posting_date``; rows with no + ``valid_from`` are treated as always-valid and used only as a + fallback. Defaults to ``SR`` (Standard Rated) when nothing matches or + the chosen ``tax_category`` isn't one of the FTA codes. """ if not item_tax_template: return "SR" - tax_category = frappe.db.get_value( - "Item Tax", - {"item_tax_template": item_tax_template}, - "tax_category", - ) - if tax_category and tax_category in _FTA_TAX_CODES: - return tax_category + bands = bands_map.get(item_tax_template) or [] + posting = getdate(posting_date) if posting_date else None + fallback_category = None + for valid_from, tax_category in bands: + if valid_from is None: + fallback_category = fallback_category or tax_category + continue + if posting is None or getdate(valid_from) <= posting: + return tax_category if tax_category in _FTA_TAX_CODES else "SR" + + if fallback_category and fallback_category in _FTA_TAX_CODES: + return fallback_category return "SR" diff --git a/erpnext/regional/report/uae_vat_201/uae_vat_201.py b/erpnext/regional/report/uae_vat_201/uae_vat_201.py index 6dc18501af7..ae50b88187c 100644 --- a/erpnext/regional/report/uae_vat_201/uae_vat_201.py +++ b/erpnext/regional/report/uae_vat_201/uae_vat_201.py @@ -2,9 +2,13 @@ # For license information, please see license.txt +from html import escape +from urllib.parse import urlencode + import frappe from frappe import _ -from frappe.query_builder.functions import Sum +from frappe.query_builder.functions import Coalesce, Sum +from frappe.utils import flt from erpnext import get_region @@ -14,6 +18,25 @@ from erpnext import get_region _cache = {} +def _drill_down_link(text, filters, **extra): + """Return an `` tag pointing at the UAE VAT Register report. + + Filter values are URL-encoded so company names with ``&`` or other + reserved characters don't break the query string, and the link text + is HTML-escaped to prevent injection from user-controlled fields. + """ + params = {} + for key in ("company", "from_date", "to_date"): + value = (filters or {}).get(key) + if value: + params[key] = value + for key, value in extra.items(): + if value is not None: + params[key] = value + query = urlencode(params) + return f'{escape(str(text))}' + + def _cached(fn): def wrapper(filters, *args, **kwargs): key = (fn.__name__, tuple(sorted((filters or {}).items()))) @@ -68,179 +91,68 @@ def get_data(filters=None): append_vat_on_expenses(data, filters) net_vat_due(data, filters, amounts_by_emirate) + emirate_drill_downs = {f"Standard rated supplies in {emirate}": emirate for emirate in get_emirates()} + dubai_legend = "Standard rated supplies in Dubai" + dubai_label_override = _company_emirate_label(filters) + final_data = [] - for i in range(0, len(data)): - if data[i].get("legend") == "Standard rated supplies in Abu Dhabi": - legend_link = f""" - {data[i].get("legend")} - """ - final_data.append( - { - "no": data[i].get("no"), - "legend": legend_link, - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) - elif data[i].get("legend") == "Standard rated supplies in Dubai": - company = frappe.defaults.get_user_default("Company") - company_filters = [ - ["Dynamic Link", "link_doctype", "=", "Company"], - ["Dynamic Link", "link_name", "=", company], - ["Address", "is_your_company_address", "=", 1], - ] - company_fields = [ - "name", - "address_line1", - "address_line2", - "city", - "state", - "country", - "emirate", - ] - address = frappe.get_all("Address", filters=company_filters, fields=company_fields) + for row in data: + legend = row.get("legend") + new_legend = legend - if address: - if address[0].get("emirate"): - name = "Standard rated supplies in" + " " + address[0].get("emirate") - else: - name = "Standard rated supplies in Dubai" - else: - name = "Standard rated supplies in Dubai" + if legend in emirate_drill_downs: + emirate = emirate_drill_downs[legend] + label = dubai_label_override if legend == dubai_legend and dubai_label_override else legend + new_legend = _drill_down_link( + label, filters, doc_type="Sales Invoice", vat=emirate, category="Standard" + ) + elif legend == "Supplies subject to the reverse charge provision": + new_legend = _drill_down_link(legend, filters, doc_type="Purchase Invoice", reverse_charge="Y") + elif legend == "Zero Rated": + new_legend = _drill_down_link(legend, filters, doc_type="Sales Invoice", category="Zero Rated") + elif legend == "Exempt Supplies": + new_legend = _drill_down_link(legend, filters, doc_type="Sales Invoice", category="Exempt Rated") + elif legend == "Standard Rated Expenses": + new_legend = _drill_down_link(legend, filters, doc_type="Purchase Invoice") - legend_link = f""" - {name} - """ - final_data.append( - { - "no": data[i].get("no"), - "legend": legend_link, - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) - - elif data[i].get("legend") == "Standard rated supplies in Sharjah": - legend_link = f""" - {data[i].get("legend")} - """ - final_data.append( - { - "no": data[i].get("no"), - "legend": legend_link, - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) - elif data[i].get("legend") == "Standard rated supplies in Ajman": - legend_link = f""" - {data[i].get("legend")} - """ - final_data.append( - { - "no": data[i].get("no"), - "legend": legend_link, - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) - elif data[i].get("legend") == "Standard rated supplies in Umm Al Quwain": - legend_link = f""" - {data[i].get("legend")} - """ - final_data.append( - { - "no": data[i].get("no"), - "legend": legend_link, - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) - elif data[i].get("legend") == "Standard rated supplies in Ras Al Khaimah": - legend_link = f""" - {data[i].get("legend")} - """ - final_data.append( - { - "no": data[i].get("no"), - "legend": legend_link, - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) - elif data[i].get("legend") == "Standard rated supplies in Fujairah": - legend_link = f""" - {data[i].get("legend")} - """ - final_data.append( - { - "no": data[i].get("no"), - "legend": legend_link, - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) - elif data[i].get("legend") == "Supplies subject to the reverse charge provision": - legend_link = f""" - {data[i].get("legend")} - """ - final_data.append( - { - "no": data[i].get("no"), - "legend": legend_link, - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) - elif data[i].get("legend") == "Zero Rated": - legend_link = f""" - {data[i].get("legend")} - """ - final_data.append( - { - "no": data[i].get("no"), - "legend": legend_link, - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) - elif data[i].get("legend") == "Exempt Supplies": - legend_link = f""" - {data[i].get("legend")} - """ - final_data.append( - { - "no": data[i].get("no"), - "legend": legend_link, - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) - elif data[i].get("legend") == "Standard Rated Expenses": - legend_link = f""" - {data[i].get("legend")} - """ - final_data.append( - { - "no": data[i].get("no"), - "legend": legend_link, - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) - else: - final_data.append( - { - "no": data[i].get("no"), - "legend": data[i].get("legend"), - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) + final_data.append( + { + "no": row.get("no"), + "legend": new_legend, + "amount": row.get("amount"), + "vat_amount": row.get("vat_amount"), + } + ) return final_data +def _company_emirate_label(filters): + """Return the home-emirate label for the company in ``filters`` if any. + + The Dubai row is conventionally relabeled with the actual emirate of + the filtered company's primary address. Falls back to ``None`` when + no company filter is set or the address has no emirate, in which case + callers keep the original "Standard rated supplies in Dubai" wording. + """ + company = (filters or {}).get("company") + if not company: + return None + address = frappe.get_all( + "Address", + filters=[ + ["Dynamic Link", "link_doctype", "=", "Company"], + ["Dynamic Link", "link_name", "=", company], + ["Address", "is_your_company_address", "=", 1], + ], + fields=["emirate"], + limit=1, + ) + if address and address[0].get("emirate"): + return _("Standard rated supplies in {0}").format(address[0]["emirate"]) + return None + + def append_vat_on_sales(data, filters): """Appends Sales and All Other Outputs.""" append_data(data, "", _("VAT on Sales and All Other Outputs"), "", "") @@ -384,7 +296,7 @@ def net_vat_due(data, filters, amounts_by_emirate): append_data( data, "13", - _("Total value of recoverable tax for the period "), + _("Total value of recoverable tax for the period"), frappe.format(0.00, "Currency"), frappe.format( get_standard_rated_expenses_tax(filters) + get_reverse_charge_recoverable_tax(filters), @@ -424,22 +336,24 @@ def format_currency_signed(value): @_cached def get_total_emiratewise(filters): """Returns Emiratewise Amount and Taxes.""" - i = frappe.qb.DocType("Sales Invoice Item") - s = frappe.qb.DocType("Sales Invoice") + si = frappe.qb.DocType("Sales Invoice") + sii = frappe.qb.DocType("Sales Invoice Item") query = ( - frappe.qb.from_(i) - .inner_join(s) - .on(i.parent == s.name) - .select(s.vat_emirate.as_("emirate"), Sum(i.base_net_amount).as_("total"), Sum(i.tax_amount)) - .where((s.docstatus == 1) & (i.is_exempt != 1) & (i.is_zero_rated != 1)) - .groupby(s.vat_emirate) + frappe.qb.from_(sii) + .inner_join(si) + .on(sii.parent == si.name) + .where(si.docstatus == 1) + .where(sii.is_exempt != 1) + .where(sii.is_zero_rated != 1) + .groupby(si.vat_emirate) + .select( + si.vat_emirate.as_("emirate"), + Coalesce(Sum(sii.base_net_amount), 0).as_("total"), + Coalesce(Sum(sii.tax_amount), 0), + ) ) - for condition in get_conditions(filters, s): - query = query.where(condition) - try: - return query.run() - except (IndexError, TypeError): - return 0 + query = _apply_period_filters(query, si, filters) + return query.run() def get_emirates(): @@ -447,255 +361,185 @@ def get_emirates(): return ["Abu Dhabi", "Dubai", "Sharjah", "Ajman", "Umm Al Quwain", "Ras Al Khaimah", "Fujairah"] -def get_filters(filters): - """The conditions to be used to filter data to calculate the total sale.""" - query_filters = [] +def _apply_period_filters(query, table, filters): + """Apply company / posting-date filters from ``filters`` to a frappe.qb query.""" + filters = filters or {} if filters.get("company"): - query_filters.append(["company", "=", filters["company"]]) + query = query.where(table.company == filters["company"]) if filters.get("from_date"): - query_filters.append(["posting_date", ">=", filters["from_date"]]) + query = query.where(table.posting_date >= filters["from_date"]) if filters.get("to_date"): - query_filters.append(["posting_date", "<=", filters["to_date"]]) - return query_filters + query = query.where(table.posting_date <= filters["to_date"]) + return query + + +def _sum_invoice_field(doctype, field, filters, extra_where=None): + """Return ``sum(field)`` on a submitted invoice doctype with the standard + period filters. ``extra_where(table)`` may yield additional ``Criterion``s.""" + table = frappe.qb.DocType(doctype) + query = frappe.qb.from_(table).where(table.docstatus == 1).select(Coalesce(Sum(table[field]), 0)) + query = _apply_period_filters(query, table, filters) + if extra_where is not None: + for criterion in extra_where(table): + query = query.where(criterion) + result = query.run() + return flt(result[0][0]) if result else 0 + + +def _sum_item_field(parent_doctype, child_doctype, field, filters, extra_item_where=None): + """Return ``sum(child.field)`` for child rows of submitted parents in the period.""" + parent = frappe.qb.DocType(parent_doctype) + child = frappe.qb.DocType(child_doctype) + query = ( + frappe.qb.from_(child) + .inner_join(parent) + .on(child.parent == parent.name) + .where(parent.docstatus == 1) + .select(Coalesce(Sum(child[field]), 0)) + ) + query = _apply_period_filters(query, parent, filters) + if extra_item_where is not None: + for criterion in extra_item_where(child): + query = query.where(criterion) + result = query.run() + return flt(result[0][0]) if result else 0 + + +def _sum_vat_account_debit(filters, recoverable=False): + """Sum of GL debit for reverse-charge purchases booked to UAE VAT Accounts. + + With ``recoverable=True``, multiplies the debit by the invoice's + ``recoverable_reverse_charge`` percentage (and only sums rows with a + non-zero recoverable rate). Returns 0 when no company filter is set, + since UAE VAT Accounts are scoped per company. + """ + if not (filters or {}).get("company"): + return 0 + + pi = frappe.qb.DocType("Purchase Invoice") + gl = frappe.qb.DocType("GL Entry") + uva = frappe.qb.DocType("UAE VAT Account") + + vat_accounts = frappe.qb.from_(uva).where(uva.parent == filters["company"]).select(uva.account) + + amount = gl.debit + if recoverable: + amount = amount * pi.recoverable_reverse_charge / 100 + + query = ( + frappe.qb.from_(pi) + .inner_join(gl) + .on(gl.voucher_no == pi.name) + .where(pi.reverse_charge == "Y") + .where(pi.docstatus == 1) + .where(gl.docstatus == 1) + .where(gl.account.isin(vat_accounts)) + .select(Coalesce(Sum(amount), 0)) + ) + if recoverable: + query = query.where(pi.recoverable_reverse_charge > 0) + query = _apply_period_filters(query, pi, filters) + result = query.run() + return flt(result[0][0]) if result else 0 @_cached def get_reverse_charge_total(filters): """Returns the sum of the total of each Purchase invoice made.""" - query_filters = get_filters(filters) - query_filters.append(["reverse_charge", "=", "Y"]) - query_filters.append(["docstatus", "=", 1]) - try: - return ( - frappe.db.get_all( - "Purchase Invoice", - filters=query_filters, - fields=["sum(base_net_total)"], - as_list=True, - limit=1, - )[0][0] - or 0 - ) - except (IndexError, TypeError): - return 0 + return _sum_invoice_field( + "Purchase Invoice", + "base_net_total", + filters, + extra_where=lambda t: [t.reverse_charge == "Y"], + ) @_cached def get_reverse_charge_tax(filters): """Returns the sum of the tax of each Purchase invoice made.""" - p = frappe.qb.DocType("Purchase Invoice") - gl = frappe.qb.DocType("GL Entry") - uae_vat = frappe.qb.DocType("UAE VAT Account") - query = ( - frappe.qb.from_(p) - .inner_join(gl) - .on(gl.voucher_no == p.name) - .select(Sum(gl.debit)) - .where( - (p.reverse_charge == "Y") - & (p.docstatus == 1) - & (gl.docstatus == 1) - & gl.account.isin( - frappe.qb.from_(uae_vat) - .select(uae_vat.account) - .where(uae_vat.parent == filters.get("company")) - ) - ) - ) - for condition in get_conditions_join(filters, p): - query = query.where(condition) - return query.run()[0][0] or 0 + return _sum_vat_account_debit(filters) @_cached def get_reverse_charge_recoverable_total(filters): """Returns the sum of the total of each Purchase invoice made with recoverable reverse charge.""" - query_filters = get_filters(filters) - query_filters.append(["reverse_charge", "=", "Y"]) - query_filters.append(["recoverable_reverse_charge", ">", "0"]) - query_filters.append(["docstatus", "=", 1]) - try: - return ( - frappe.db.get_all( - "Purchase Invoice", - filters=query_filters, - fields=["sum(base_net_total)"], - as_list=True, - limit=1, - )[0][0] - or 0 - ) - except (IndexError, TypeError): - return 0 + return _sum_invoice_field( + "Purchase Invoice", + "base_net_total", + filters, + extra_where=lambda t: [t.reverse_charge == "Y", t.recoverable_reverse_charge > 0], + ) @_cached def get_reverse_charge_recoverable_tax(filters): """Returns the sum of the tax of each Purchase invoice made.""" - p = frappe.qb.DocType("Purchase Invoice") - gl = frappe.qb.DocType("GL Entry") - uae_vat = frappe.qb.DocType("UAE VAT Account") - query = ( - frappe.qb.from_(p) - .inner_join(gl) - .on(gl.voucher_no == p.name) - .select(Sum(gl.debit * p.recoverable_reverse_charge / 100)) - .where( - (p.reverse_charge == "Y") - & (p.docstatus == 1) - & (p.recoverable_reverse_charge > 0) - & (gl.docstatus == 1) - & gl.account.isin( - frappe.qb.from_(uae_vat) - .select(uae_vat.account) - .where(uae_vat.parent == filters.get("company")) - ) - ) - ) - for condition in get_conditions_join(filters, p): - query = query.where(condition) - return query.run()[0][0] or 0 - - -def get_conditions_join(filters, p): - """The conditions to be used to filter data to calculate the total vat.""" - conditions = [] - if filters.get("company"): - conditions.append(p.company == filters.get("company")) - if filters.get("from_date"): - conditions.append(p.posting_date >= filters.get("from_date")) - if filters.get("to_date"): - conditions.append(p.posting_date <= filters.get("to_date")) - return conditions + return _sum_vat_account_debit(filters, recoverable=True) @_cached def get_standard_rated_expenses_total(filters): """Returns the sum of the total of each Purchase invoice made with recoverable reverse charge.""" - query_filters = get_filters(filters) - query_filters.append(["recoverable_standard_rated_expenses", ">", 0]) - query_filters.append(["docstatus", "=", 1]) - try: - return ( - frappe.db.get_all( - "Purchase Invoice", - filters=query_filters, - fields=["sum(base_net_total)"], - as_list=True, - limit=1, - )[0][0] - or 0 - ) - except (IndexError, TypeError): - return 0 + return _sum_invoice_field( + "Purchase Invoice", + "base_net_total", + filters, + extra_where=lambda t: [t.recoverable_standard_rated_expenses > 0], + ) @_cached def get_standard_rated_expenses_tax(filters): """Returns the sum of the tax of each Purchase invoice made.""" - query_filters = get_filters(filters) - query_filters.append(["recoverable_standard_rated_expenses", ">", 0]) - query_filters.append(["docstatus", "=", 1]) - try: - return ( - frappe.db.get_all( - "Purchase Invoice", - filters=query_filters, - fields=["sum(recoverable_standard_rated_expenses)"], - as_list=True, - limit=1, - )[0][0] - or 0 - ) - except (IndexError, TypeError): - return 0 + return _sum_invoice_field( + "Purchase Invoice", + "recoverable_standard_rated_expenses", + filters, + extra_where=lambda t: [t.recoverable_standard_rated_expenses > 0], + ) @_cached def get_tourist_tax_return_total(filters): """Returns the sum of the total of each Sales invoice with non zero tourist_tax_return.""" - query_filters = get_filters(filters) - query_filters.append(["tourist_tax_return", ">", 0]) - query_filters.append(["docstatus", "=", 1]) - try: - return ( - frappe.db.get_all( - "Sales Invoice", filters=query_filters, fields=["sum(base_net_total)"], as_list=True, limit=1 - )[0][0] - or 0 - ) - except (IndexError, TypeError): - return 0 + return _sum_invoice_field( + "Sales Invoice", + "base_net_total", + filters, + extra_where=lambda t: [t.tourist_tax_return > 0], + ) @_cached def get_tourist_tax_return_tax(filters): """Returns the sum of the tax of each Sales invoice with non zero tourist_tax_return.""" - query_filters = get_filters(filters) - query_filters.append(["tourist_tax_return", ">", 0]) - query_filters.append(["docstatus", "=", 1]) - try: - return ( - frappe.db.get_all( - "Sales Invoice", - filters=query_filters, - fields=["sum(tourist_tax_return)"], - as_list=True, - limit=1, - )[0][0] - or 0 - ) - except (IndexError, TypeError): - return 0 + return _sum_invoice_field( + "Sales Invoice", + "tourist_tax_return", + filters, + extra_where=lambda t: [t.tourist_tax_return > 0], + ) @_cached def get_zero_rated_total(filters): """Returns the sum of each Sales Invoice Item Amount which is zero rated.""" - i = frappe.qb.DocType("Sales Invoice Item") - s = frappe.qb.DocType("Sales Invoice") - query = ( - frappe.qb.from_(i) - .inner_join(s) - .on(i.parent == s.name) - .select(Sum(i.base_net_amount).as_("total")) - .where((s.docstatus == 1) & (i.is_zero_rated == 1)) + return _sum_item_field( + "Sales Invoice", + "Sales Invoice Item", + "base_net_amount", + filters, + extra_item_where=lambda i: [i.is_zero_rated == 1], ) - for condition in get_conditions(filters, s): - query = query.where(condition) - try: - return query.run()[0][0] or 0 - except (IndexError, TypeError): - return 0 @_cached def get_exempt_total(filters): """Returns the sum of each Sales Invoice Item Amount which is Vat Exempt.""" - i = frappe.qb.DocType("Sales Invoice Item") - s = frappe.qb.DocType("Sales Invoice") - query = ( - frappe.qb.from_(i) - .inner_join(s) - .on(i.parent == s.name) - .select(Sum(i.base_net_amount).as_("total")) - .where((s.docstatus == 1) & (i.is_exempt == 1)) + return _sum_item_field( + "Sales Invoice", + "Sales Invoice Item", + "base_net_amount", + filters, + extra_item_where=lambda i: [i.is_exempt == 1], ) - for condition in get_conditions(filters, s): - query = query.where(condition) - try: - return query.run()[0][0] or 0 - except (IndexError, TypeError): - return 0 - - -def get_conditions(filters, s): - """The conditions to be used to filter data to calculate the total sale.""" - conditions = [] - if filters.get("company"): - conditions.append(s.company == filters.get("company")) - if filters.get("from_date"): - conditions.append(s.posting_date >= filters.get("from_date")) - if filters.get("to_date"): - conditions.append(s.posting_date <= filters.get("to_date")) - return conditions diff --git a/erpnext/regional/report/uae_vat_register/uae_vat_register.py b/erpnext/regional/report/uae_vat_register/uae_vat_register.py index 04427f7359a..534e914936c 100644 --- a/erpnext/regional/report/uae_vat_register/uae_vat_register.py +++ b/erpnext/regional/report/uae_vat_register/uae_vat_register.py @@ -4,6 +4,7 @@ import frappe from frappe import _ +from frappe.query_builder.functions import Coalesce, Sum def execute(filters=None): @@ -88,129 +89,109 @@ def get_columns(filters): def get_data(filters): doc_type = filters.get("doc_type") or "Sales Invoice" if doc_type == "Sales Invoice": - return fetch_sales_rows(filters) + return _fetch_rows(filters, is_sales=True) if doc_type == "Purchase Invoice": - return fetch_purchase_rows(filters) + return _fetch_rows(filters, is_sales=False) return [] -def fetch_sales_rows(filters): - conditions, params = build_conditions(filters) - category_clause = sales_category_clause(filters.get("category")) +def _fetch_rows(filters, is_sales): + """Build the VAT register query for either Sales or Purchase Invoices. - emirate_clause = "" - if filters.get("vat"): - emirate_clause = "AND s.vat_emirate = %(vat)s" - params["vat"] = filters["vat"] + Item-wise mode returns one row per Sales/Purchase Invoice Item; the + default mode aggregates back to one row per invoice with summed qty, + net, VAT, and total. ``COALESCE(i.tax_amount, 0)`` is used everywhere + so a missing VAT amount surfaces as 0 instead of NULL — matching the + currency display and avoiding NULLs in client-side totals. + """ + parent_doctype = "Sales Invoice" if is_sales else "Purchase Invoice" + child_doctype = "Sales Invoice Item" if is_sales else "Purchase Invoice Item" + parent = frappe.qb.DocType(parent_doctype) + child = frappe.qb.DocType(child_doctype) + item_wise = bool(filters.get("item_wise")) - if filters.get("item_wise"): - return frappe.db.sql( - f""" - SELECT - s.name, s.posting_date, s.customer AS party, - COALESCE(i.cost_center, s.cost_center) AS cost_center, - s.vat_emirate AS emirate, - i.item_code, i.qty, i.rate, - i.base_net_amount AS net_amount, - i.tax_amount AS vat_amount, - (i.base_net_amount + COALESCE(i.tax_amount, 0)) AS total_amount - FROM `tabSales Invoice` s - INNER JOIN `tabSales Invoice Item` i ON i.parent = s.name - WHERE s.docstatus = 1 {conditions} {category_clause} {emirate_clause} - ORDER BY s.posting_date, s.name, i.idx - """, - params, - as_dict=True, + party_field = parent.customer if is_sales else parent.supplier + party_extra = parent.vat_emirate.as_("emirate") if is_sales else parent.reverse_charge + + tax_amount = Coalesce(child.tax_amount, 0) + gross = child.base_net_amount + tax_amount + + if item_wise: + query = ( + frappe.qb.from_(parent) + .inner_join(child) + .on(child.parent == parent.name) + .where(parent.docstatus == 1) + .select( + parent.name, + parent.posting_date, + party_field.as_("party"), + Coalesce(child.cost_center, parent.cost_center).as_("cost_center"), + party_extra, + child.item_code, + child.qty, + child.rate, + child.base_net_amount.as_("net_amount"), + tax_amount.as_("vat_amount"), + gross.as_("total_amount"), + ) + .orderby(parent.posting_date) + .orderby(parent.name) + .orderby(child.idx) + ) + else: + cost_center = parent.cost_center + query = ( + frappe.qb.from_(parent) + .inner_join(child) + .on(child.parent == parent.name) + .where(parent.docstatus == 1) + .select( + parent.name, + parent.posting_date, + party_field.as_("party"), + cost_center, + party_extra, + Sum(child.qty).as_("qty"), + Coalesce(Sum(child.base_net_amount), 0).as_("net_amount"), + Coalesce(Sum(tax_amount), 0).as_("vat_amount"), + Coalesce(Sum(gross), 0).as_("total_amount"), + ) + .groupby(parent.name, parent.posting_date, party_field, cost_center, party_extra) + .orderby(parent.posting_date) + .orderby(parent.name) ) - return frappe.db.sql( - f""" - SELECT - s.name, s.posting_date, s.customer AS party, s.cost_center, - s.vat_emirate AS emirate, - SUM(i.qty) AS qty, - SUM(i.base_net_amount) AS net_amount, - SUM(i.tax_amount) AS vat_amount, - SUM(i.base_net_amount + COALESCE(i.tax_amount, 0)) AS total_amount - FROM `tabSales Invoice` s - INNER JOIN `tabSales Invoice Item` i ON i.parent = s.name - WHERE s.docstatus = 1 {conditions} {category_clause} {emirate_clause} - GROUP BY s.name, s.posting_date, s.customer, s.cost_center, s.vat_emirate - ORDER BY s.posting_date, s.name - """, - params, - as_dict=True, - ) + query = _apply_period_filters(query, parent, filters) + + if is_sales and filters.get("vat"): + query = query.where(parent.vat_emirate == filters["vat"]) + if not is_sales and filters.get("reverse_charge") in ("Y", "N"): + query = query.where(parent.reverse_charge == filters["reverse_charge"]) + if is_sales: + category_criterion = _sales_category_criterion(child, filters.get("category")) + if category_criterion is not None: + query = query.where(category_criterion) + + return query.run(as_dict=True) -def fetch_purchase_rows(filters): - conditions, params = build_conditions(filters) - - rc_clause = "" - if filters.get("reverse_charge") in ("Y", "N"): - rc_clause = "AND s.reverse_charge = %(reverse_charge)s" - params["reverse_charge"] = filters["reverse_charge"] - - if filters.get("item_wise"): - return frappe.db.sql( - f""" - SELECT - s.name, s.posting_date, s.supplier AS party, - COALESCE(i.cost_center, s.cost_center) AS cost_center, - s.reverse_charge, - i.item_code, i.qty, i.rate, - i.base_net_amount AS net_amount, - i.tax_amount AS vat_amount, - (i.base_net_amount + COALESCE(i.tax_amount, 0)) AS total_amount - FROM `tabPurchase Invoice` s - INNER JOIN `tabPurchase Invoice Item` i ON i.parent = s.name - WHERE s.docstatus = 1 {conditions} {rc_clause} - ORDER BY s.posting_date, s.name, i.idx - """, - params, - as_dict=True, - ) - - return frappe.db.sql( - f""" - SELECT - s.name, s.posting_date, s.supplier AS party, s.cost_center, - s.reverse_charge, - SUM(i.qty) AS qty, - SUM(i.base_net_amount) AS net_amount, - SUM(i.tax_amount) AS vat_amount, - SUM(i.base_net_amount + COALESCE(i.tax_amount, 0)) AS total_amount - FROM `tabPurchase Invoice` s - INNER JOIN `tabPurchase Invoice Item` i ON i.parent = s.name - WHERE s.docstatus = 1 {conditions} {rc_clause} - GROUP BY s.name, s.posting_date, s.supplier, s.cost_center, s.reverse_charge - ORDER BY s.posting_date, s.name - """, - params, - as_dict=True, - ) - - -def build_conditions(filters): - conditions = "" - params = {} +def _apply_period_filters(query, parent, filters): if filters.get("company"): - conditions += " AND s.company = %(company)s" - params["company"] = filters["company"] + query = query.where(parent.company == filters["company"]) if filters.get("from_date"): - conditions += " AND s.posting_date >= %(from_date)s" - params["from_date"] = filters["from_date"] + query = query.where(parent.posting_date >= filters["from_date"]) if filters.get("to_date"): - conditions += " AND s.posting_date <= %(to_date)s" - params["to_date"] = filters["to_date"] - return conditions, params + query = query.where(parent.posting_date <= filters["to_date"]) + return query -def sales_category_clause(category): +def _sales_category_criterion(child, category): + """Translate the ``category`` filter into a Sales Invoice Item criterion.""" if category == "Standard": - return "AND i.is_zero_rated != 1 AND i.is_exempt != 1" + return (child.is_zero_rated != 1) & (child.is_exempt != 1) if category == "Zero Rated": - return "AND i.is_zero_rated = 1" + return child.is_zero_rated == 1 if category == "Exempt Rated": - return "AND i.is_exempt = 1" - return "" + return child.is_exempt == 1 + return None From dffe4bd22d50bec11bfdc831c4fb6304b2f3938d Mon Sep 17 00:00:00 2001 From: Bibin <17405044+bibinqcs@users.noreply.github.com> Date: Sun, 14 Jun 2026 11:37:35 +0000 Subject: [PATCH 3/7] feat(FTA Audit File): Enhance FAF generation logic and error handling; update currency handling in VAT reports --- .../doctype/fta_audit_file/fta_audit_file.js | 9 ++- .../doctype/fta_audit_file/fta_audit_file.py | 78 ++++++++++++------- .../report/uae_vat_201/uae_vat_201.py | 52 +++++++++---- 3 files changed, 93 insertions(+), 46 deletions(-) diff --git a/erpnext/regional/doctype/fta_audit_file/fta_audit_file.js b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.js index 5240da2e314..233819b190d 100644 --- a/erpnext/regional/doctype/fta_audit_file/fta_audit_file.js +++ b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.js @@ -3,10 +3,13 @@ frappe.ui.form.on("FTA Audit File", { refresh: function (frm) { - // Add Generate FAF button for Draft status - if (frm.doc.status === "Draft" && !frm.is_new()) { + // Generate FAF — available from Draft (first generation) and Error + // (retry after a previous attempt failed). Queued/Generating are + // blocked by the server guard; Generated/Submitted are intentionally + // not re-generable. + if (!frm.is_new() && ["Draft", "Error"].includes(frm.doc.status)) { frm.add_custom_button( - __("Generate FAF"), + __(frm.doc.status === "Error" ? "Retry FAF Generation" : "Generate FAF"), function () { frm.trigger("generate_faf"); }, diff --git a/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py index 1356c76a744..366705c3079 100644 --- a/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py +++ b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py @@ -17,8 +17,10 @@ delimited by an explicit start/end marker row: 4. General Ledger (GLDataStart .. GLDataEnd) The footer of each transactional table carries running totals plus a -transaction count. All amounts are in AED (foreign-currency mirrors are -emitted alongside when the source invoice is non-AED). +transaction count. Primary amount columns are in the company's accounting +currency (typically AED for a UAE-registered entity); foreign-currency +mirrors are emitted alongside when the source invoice is in a different +currency. """ import csv @@ -169,7 +171,14 @@ class FTAAuditFile(Document): err_doc.generation_log = (err_doc.generation_log or "") + f"\n\nError: {e}" err_doc.save() except Exception: - pass + # Don't lose the original failure if persisting the Error + # state itself fails (e.g. row lock, validation regression); + # log the secondary failure with context, then re-raise the + # original ``e`` below so the job is still marked failed. + frappe.log_error( + title=_("FAF Error-state persistence failed"), + message=f"{self.doctype} {self.name}\n\n{frappe.get_traceback()}", + ) frappe.log_error( title=_("FAF Generation Error"), message=frappe.get_traceback(), @@ -307,8 +316,8 @@ class FTAAuditFile(Document): company_currency = _company_currency(self.company) - total_purchase_aed = 0.0 - total_vat_aed = 0.0 + total_purchase_company = 0.0 + total_vat_company = 0.0 line_count = 0 for inv in invoices: @@ -316,10 +325,15 @@ class FTAAuditFile(Document): fcy_code, fcy_factor = _fcy_for_invoice(inv.currency, inv.conversion_rate, company_currency) for item in items_by_invoice.get(inv.name, []): - net_aed = flt(item.base_net_amount, 2) - vat_aed = flt(item.tax_amount or 0, 2) - net_fcy = flt((item.net_amount or 0) if fcy_code != "XXX" else 0, 2) - vat_fcy = flt(vat_aed / fcy_factor if fcy_factor else 0, 2) if fcy_code != "XXX" else 0.0 + # base_net_amount is company-currency; tax_amount is a UAE + # custom field with options="currency" and therefore stored + # in the document's invoice currency. Multiply by the + # conversion rate to land in company currency. + net_company = flt(item.base_net_amount, 2) + vat_invoice = flt(item.tax_amount or 0, 2) + vat_company = flt(vat_invoice * fcy_factor, 2) + net_fcy = flt(item.net_amount or 0, 2) if fcy_code != "XXX" else 0.0 + vat_fcy = vat_invoice if fcy_code != "XXX" else 0.0 writer.writerow( [ @@ -330,23 +344,23 @@ class FTAAuditFile(Document): inv.permit_no or "", item.idx, _clean(item.description or item.item_name or ""), - _money(net_aed), - _money(vat_aed), + _money(net_company), + _money(vat_company), _resolve_tax_code(item.item_tax_template, inv.posting_date, tax_code_bands), fcy_code, _money(net_fcy), _money(vat_fcy), ] ) - total_purchase_aed += net_aed - total_vat_aed += vat_aed + total_purchase_company += net_company + total_vat_company += vat_company line_count += 1 writer.writerow( [ "PurcDataEnd", - _money(total_purchase_aed), - _money(total_vat_aed), + _money(total_purchase_company), + _money(total_vat_company), line_count, ] ) @@ -409,8 +423,8 @@ class FTAAuditFile(Document): company_currency = _company_currency(self.company) - total_supply_aed = 0.0 - total_vat_aed = 0.0 + total_supply_company = 0.0 + total_vat_company = 0.0 line_count = 0 for inv in invoices: @@ -419,10 +433,14 @@ class FTAAuditFile(Document): fcy_code, fcy_factor = _fcy_for_invoice(inv.currency, inv.conversion_rate, company_currency) for item in items_by_invoice.get(inv.name, []): - net_aed = flt(item.base_net_amount, 2) - vat_aed = flt(item.tax_amount or 0, 2) - net_fcy = flt((item.net_amount or 0) if fcy_code != "XXX" else 0, 2) - vat_fcy = flt(vat_aed / fcy_factor if fcy_factor else 0, 2) if fcy_code != "XXX" else 0.0 + # See _write_purchase_listing for the currency convention: + # tax_amount is invoice-currency, base_net_amount is + # company-currency, and fcy_factor converts invoice → company. + net_company = flt(item.base_net_amount, 2) + vat_invoice = flt(item.tax_amount or 0, 2) + vat_company = flt(vat_invoice * fcy_factor, 2) + net_fcy = flt(item.net_amount or 0, 2) if fcy_code != "XXX" else 0.0 + vat_fcy = vat_invoice if fcy_code != "XXX" else 0.0 if item.is_zero_rated: tax_code = "ZR" @@ -439,8 +457,8 @@ class FTAAuditFile(Document): inv.name, item.idx, _clean(item.description or item.item_name or ""), - _money(net_aed), - _money(vat_aed), + _money(net_company), + _money(vat_company), tax_code, _clean(customer_country), fcy_code, @@ -448,15 +466,15 @@ class FTAAuditFile(Document): _money(vat_fcy), ] ) - total_supply_aed += net_aed - total_vat_aed += vat_aed + total_supply_company += net_company + total_vat_company += vat_company line_count += 1 writer.writerow( [ "SuppDataEnd", - _money(total_supply_aed), - _money(total_vat_aed), + _money(total_supply_company), + _money(total_vat_company), line_count, ] ) @@ -466,6 +484,8 @@ class FTAAuditFile(Document): """Emit General Ledger per Appendix 5 with end-of-table totals row.""" writer.writerow(["GLDataStart"]) + company_currency = _company_currency(self.company) + entries = frappe.get_all( "GL Entry", filters={ @@ -487,7 +507,7 @@ class FTAAuditFile(Document): order_by="posting_date asc, creation asc", ) if not entries: - writer.writerow(["GLDataEnd", _money(0), _money(0), 0, "AED"]) + writer.writerow(["GLDataEnd", _money(0), _money(0), 0, company_currency]) return 0 account_names = list({e.account for e in entries if e.account}) @@ -546,7 +566,7 @@ class FTAAuditFile(Document): _money(total_debit), _money(total_credit), count, - "AED", + company_currency, ] ) return count diff --git a/erpnext/regional/report/uae_vat_201/uae_vat_201.py b/erpnext/regional/report/uae_vat_201/uae_vat_201.py index ae50b88187c..e795e7709e9 100644 --- a/erpnext/regional/report/uae_vat_201/uae_vat_201.py +++ b/erpnext/regional/report/uae_vat_201/uae_vat_201.py @@ -48,6 +48,7 @@ def _cached(fn): def execute(filters=None): + filters = filters or {} validate_company_region(filters) _cache.clear() columns = get_columns() @@ -91,28 +92,27 @@ def get_data(filters=None): append_vat_on_expenses(data, filters) net_vat_due(data, filters, amounts_by_emirate) - emirate_drill_downs = {f"Standard rated supplies in {emirate}": emirate for emirate in get_emirates()} - dubai_legend = "Standard rated supplies in Dubai" dubai_label_override = _company_emirate_label(filters) final_data = [] for row in data: + key = row.get("_key") legend = row.get("legend") new_legend = legend - if legend in emirate_drill_downs: - emirate = emirate_drill_downs[legend] - label = dubai_label_override if legend == dubai_legend and dubai_label_override else legend + if key and key.startswith("emirate:"): + emirate = key.split(":", 1)[1] + label = dubai_label_override if emirate == "Dubai" and dubai_label_override else legend new_legend = _drill_down_link( label, filters, doc_type="Sales Invoice", vat=emirate, category="Standard" ) - elif legend == "Supplies subject to the reverse charge provision": + elif key == "reverse_charge_supplies": new_legend = _drill_down_link(legend, filters, doc_type="Purchase Invoice", reverse_charge="Y") - elif legend == "Zero Rated": + elif key == "zero_rated": new_legend = _drill_down_link(legend, filters, doc_type="Sales Invoice", category="Zero Rated") - elif legend == "Exempt Supplies": + elif key == "exempt_supplies": new_legend = _drill_down_link(legend, filters, doc_type="Sales Invoice", category="Exempt Rated") - elif legend == "Standard Rated Expenses": + elif key == "standard_rated_expenses": new_legend = _drill_down_link(legend, filters, doc_type="Purchase Invoice") final_data.append( @@ -176,11 +176,26 @@ def append_vat_on_sales(data, filters): _("Supplies subject to the reverse charge provision"), frappe.format(get_reverse_charge_total(filters), "Currency"), frappe.format(get_reverse_charge_tax(filters), "Currency"), + key="reverse_charge_supplies", ) - append_data(data, "4", _("Zero Rated"), frappe.format(get_zero_rated_total(filters), "Currency"), "-") + append_data( + data, + "4", + _("Zero Rated"), + frappe.format(get_zero_rated_total(filters), "Currency"), + "-", + key="zero_rated", + ) - append_data(data, "5", _("Exempt Supplies"), frappe.format(get_exempt_total(filters), "Currency"), "-") + append_data( + data, + "5", + _("Exempt Supplies"), + frappe.format(get_exempt_total(filters), "Currency"), + "-", + key="exempt_supplies", + ) append_data( data, @@ -230,6 +245,7 @@ def append_emiratewise_expenses(data, emirates, amounts_by_emirate): if emirate in amounts_by_emirate: amounts_by_emirate[emirate]["no"] = _("1{0}").format(chr(no)) amounts_by_emirate[emirate]["legend"] = _("Standard rated supplies in {0}").format(emirate) + amounts_by_emirate[emirate]["_key"] = f"emirate:{emirate}" data.append(amounts_by_emirate[emirate]) s_amount.append(amounts_by_emirate[emirate].get("raw_amount") or 0) @@ -241,6 +257,7 @@ def append_emiratewise_expenses(data, emirates, amounts_by_emirate): _("Standard rated supplies in {0}").format(emirate), frappe.format(0, "Currency"), frappe.format(0, "Currency"), + key=f"emirate:{emirate}", ) return amounts_by_emirate, s_amount, v_amount @@ -254,6 +271,7 @@ def append_vat_on_expenses(data, filters): _("Standard Rated Expenses"), frappe.format(get_standard_rated_expenses_total(filters), "Currency"), frappe.format(get_standard_rated_expenses_tax(filters), "Currency"), + key="standard_rated_expenses", ) append_data( data, @@ -318,9 +336,15 @@ def net_vat_due(data, filters, amounts_by_emirate): ) -def append_data(data, no, legend, amount, vat_amount): - """Returns data with appended value.""" - data.append({"no": no, "legend": legend, "amount": amount, "vat_amount": vat_amount}) +def append_data(data, no, legend, amount, vat_amount, key=None): + """Append one row to ``data``. + + ``key`` (when provided) is a language-independent identifier used by + ``get_data`` to decide which rows get drill-down links. Without it, + dispatch would have to match the localized ``legend`` text and would + silently break under any non-English language. + """ + data.append({"no": no, "legend": legend, "amount": amount, "vat_amount": vat_amount, "_key": key}) def format_currency_signed(value): From 73166979a2e86aa353ba2c32d6d558a745a0f0c3 Mon Sep 17 00:00:00 2001 From: Bibin <17405044+bibinqcs@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:26:10 +0000 Subject: [PATCH 4/7] test(FTA Audit File): drop redundant tearDown override ERPNextTestSuite already calls frappe.db.rollback() in its base tearDown; overriding (even with the same call) trips the semgrep "Dont-override-teardown" rule. --- erpnext/regional/doctype/fta_audit_file/test_fta_audit_file.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/erpnext/regional/doctype/fta_audit_file/test_fta_audit_file.py b/erpnext/regional/doctype/fta_audit_file/test_fta_audit_file.py index b5f76558d24..22dba1e02d5 100644 --- a/erpnext/regional/doctype/fta_audit_file/test_fta_audit_file.py +++ b/erpnext/regional/doctype/fta_audit_file/test_fta_audit_file.py @@ -253,6 +253,3 @@ class TestFTAAuditFile(FrappeTestCase): self.assertTrue(result["success"]) doc.reload() self.assertEqual(doc.status, "Submitted") - - def tearDown(self): - frappe.db.rollback() From f78683c14b4e226c659a6bf2793c2ab7f3b539d3 Mon Sep 17 00:00:00 2001 From: Bibin <17405044+bibinqcs@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:33:26 +0000 Subject: [PATCH 5/7] fix(UAE Regional): address greptile review findings - Gate generate_faf() and mark_as_submitted() on write permission so REST callers without write access can no longer trigger state changes via the whitelisted endpoints. - Drop test_generate_faf_excise_not_yet_implemented; the Excise file type is no longer a valid Select option, so doc.insert() now fails before generate_faf() is reached. - Stream GL Entry rows in pages of GL_PAGE_SIZE to bound memory on multi-year exports against large companies; running balance, account-name cache, and totals carry across batches so output is byte-identical to the single-fetch implementation. - Move the VAT 201 helper cache from a module-level dict to frappe.local so concurrent requests on threaded workers no longer race or leak data across users. --- .../doctype/fta_audit_file/fta_audit_file.py | 146 +++++++++++------- .../fta_audit_file/test_fta_audit_file.py | 17 -- .../report/uae_vat_201/uae_vat_201.py | 26 +++- 3 files changed, 109 insertions(+), 80 deletions(-) diff --git a/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py index 366705c3079..e228b67d00f 100644 --- a/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py +++ b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py @@ -37,6 +37,12 @@ DEFAULT_COUNTRY = "United Arab Emirates" DEFAULT_DATE = "31-12-9999" PRODUCT_VERSION = "ERPNext" +# Number of GL Entry rows read into memory per batch when streaming the +# General Ledger section. Large UAE companies routinely produce hundreds +# of thousands of GL entries per year; reading them all into a single list +# would push the background worker over its memory limit. +GL_PAGE_SIZE = 5000 + # Inputs that define what the FAF file represents. Once the file has been # Generated or Submitted, changing any of these would silently desync the # attached CSV from the form, so we lock them. @@ -105,6 +111,11 @@ class FTAAuditFile(Document): Under ``frappe.flags.in_test`` the job runs synchronously so tests can assert on the post-generation state without polling. """ + # `@frappe.whitelist()` gates network access but not document write + # permission; without this explicit check, a user with read-only + # access could still trigger generation via REST. + self.check_permission("write") + # Re-read status from DB so two concurrent button clicks can't both # enqueue a job — the second one sees Queued/Generating and bails. current_status = frappe.db.get_value(self.doctype, self.name, "status", for_update=True) @@ -138,6 +149,7 @@ class FTAAuditFile(Document): @frappe.whitelist() def mark_as_submitted(self): """Mark the FAF as submitted to the FTA portal (manual record).""" + self.check_permission("write") if self.status != "Generated": frappe.throw(_("Only Generated files can be marked as Submitted")) self.status = "Submitted" @@ -481,42 +493,34 @@ class FTAAuditFile(Document): return line_count def _write_gl_listing(self, writer): - """Emit General Ledger per Appendix 5 with end-of-table totals row.""" + """Emit General Ledger per Appendix 5 with end-of-table totals row. + + GL Entry rows are streamed in pages of ``GL_PAGE_SIZE`` to keep + memory bounded for multi-year exports on large companies; the + running-balance, account-name, and totals state survives across + pages so the output is identical to a single-fetch implementation. + """ writer.writerow(["GLDataStart"]) company_currency = _company_currency(self.company) + base_filters = { + "company": self.company, + "posting_date": ["between", [self.from_date, self.to_date]], + "is_cancelled": 0, + } - entries = frappe.get_all( - "GL Entry", - filters={ - "company": self.company, - "posting_date": ["between", [self.from_date, self.to_date]], - "is_cancelled": 0, - }, - fields=[ - "name", - "posting_date", - "account", - "remarks", - "against", - "voucher_no", - "voucher_type", - "debit", - "credit", - ], - order_by="posting_date asc, creation asc", - ) - if not entries: - writer.writerow(["GLDataEnd", _money(0), _money(0), 0, company_currency]) - return 0 - - account_names = list({e.account for e in entries if e.account}) - account_name_map = _bulk_party_field("Account", account_names, "account_name") - + # Opening balances need every account that posts in the period up + # front; without that flag we cache account names lazily as we + # encounter them in each batch. if self.include_opening_balance: - running_balance = _opening_balances_by_account(self.company, self.from_date, account_names) + accounts_in_period = frappe.get_all( + "GL Entry", filters=base_filters, pluck="account", distinct=True + ) + running_balance = _opening_balances_by_account(self.company, self.from_date, accounts_in_period) + account_name_map = _bulk_party_field("Account", accounts_in_period, "account_name") else: running_balance = {} + account_name_map = {} source_type_map = { "Sales Invoice": "AR", @@ -531,34 +535,66 @@ class FTAAuditFile(Document): total_debit = 0.0 total_credit = 0.0 count = 0 + start = 0 - for entry in entries: - account_name = account_name_map.get(entry.account) or entry.account - source_type = source_type_map.get(entry.voucher_type, entry.voucher_type or "") - debit = flt(entry.debit, 2) - credit = flt(entry.credit, 2) - - running_balance[entry.account] = running_balance.get(entry.account, 0.0) + debit - credit - balance = flt(running_balance[entry.account], 2) - - writer.writerow( - [ - _format_date(entry.posting_date), - entry.account, - _clean(account_name), - _clean(entry.remarks or ""), - _clean(entry.against or ""), - entry.voucher_no, - entry.voucher_no, - source_type, - _money(debit), - _money(credit), - _money(balance), - ] + while True: + batch = frappe.get_all( + "GL Entry", + filters=base_filters, + fields=[ + "name", + "posting_date", + "account", + "remarks", + "against", + "voucher_no", + "voucher_type", + "debit", + "credit", + ], + order_by="posting_date asc, creation asc", + limit_start=start, + limit_page_length=GL_PAGE_SIZE, ) - total_debit += debit - total_credit += credit - count += 1 + if not batch: + break + + # Backfill the account-name cache for accounts new to this batch. + new_accounts = [e.account for e in batch if e.account and e.account not in account_name_map] + if new_accounts: + account_name_map.update(_bulk_party_field("Account", new_accounts, "account_name")) + + for entry in batch: + account_name = account_name_map.get(entry.account) or entry.account + source_type = source_type_map.get(entry.voucher_type, entry.voucher_type or "") + debit = flt(entry.debit, 2) + credit = flt(entry.credit, 2) + + running_balance[entry.account] = running_balance.get(entry.account, 0.0) + debit - credit + balance = flt(running_balance[entry.account], 2) + + writer.writerow( + [ + _format_date(entry.posting_date), + entry.account, + _clean(account_name), + _clean(entry.remarks or ""), + _clean(entry.against or ""), + entry.voucher_no, + entry.voucher_no, + source_type, + _money(debit), + _money(credit), + _money(balance), + ] + ) + total_debit += debit + total_credit += credit + count += 1 + + if len(batch) < GL_PAGE_SIZE: + break + start += GL_PAGE_SIZE writer.writerow( [ diff --git a/erpnext/regional/doctype/fta_audit_file/test_fta_audit_file.py b/erpnext/regional/doctype/fta_audit_file/test_fta_audit_file.py index 22dba1e02d5..2754c53cbba 100644 --- a/erpnext/regional/doctype/fta_audit_file/test_fta_audit_file.py +++ b/erpnext/regional/doctype/fta_audit_file/test_fta_audit_file.py @@ -213,23 +213,6 @@ class TestFTAAuditFile(FrappeTestCase): self.assertNotIn("SuppDataEnd,0.0,", csv_content) self.assertNotIn("GLDataEnd,0.0,", csv_content) - def test_generate_faf_excise_not_yet_implemented(self): - """Excise FAF (Appendix 6) should error cleanly until implemented.""" - doc = frappe.get_doc( - { - "doctype": "FTA Audit File", - "company": self.company, - "from_date": "2099-03-01", - "to_date": "2099-03-31", - "file_type": "Excise", - } - ) - doc.insert() - - self.assertRaises(frappe.ValidationError, doc.generate_faf) - doc.reload() - self.assertEqual(doc.status, "Error") - def test_mark_as_submitted_workflow(self): """Generated docs can be marked submitted; non-Generated cannot.""" doc = frappe.get_doc( diff --git a/erpnext/regional/report/uae_vat_201/uae_vat_201.py b/erpnext/regional/report/uae_vat_201/uae_vat_201.py index e795e7709e9..e7979283f0f 100644 --- a/erpnext/regional/report/uae_vat_201/uae_vat_201.py +++ b/erpnext/regional/report/uae_vat_201/uae_vat_201.py @@ -12,10 +12,19 @@ from frappe.utils import flt from erpnext import get_region -# Per-execution memoization cache for the helper functions below. -# Cleared at the start of every execute() call so each report run gets -# fresh data; within a single run, repeated calls reuse the result. -_cache = {} +# Per-request memoization cache for the helper functions below. Stored on +# ``frappe.local`` so concurrent requests under gevent/threaded workers +# never share or race on this state; cleared at the start of every +# ``execute()`` so each report run gets fresh data. +_CACHE_ATTR = "_uae_vat_201_cache" + + +def _get_cache(): + cache = getattr(frappe.local, _CACHE_ATTR, None) + if cache is None: + cache = {} + setattr(frappe.local, _CACHE_ATTR, cache) + return cache def _drill_down_link(text, filters, **extra): @@ -39,10 +48,11 @@ def _drill_down_link(text, filters, **extra): def _cached(fn): def wrapper(filters, *args, **kwargs): + cache = _get_cache() key = (fn.__name__, tuple(sorted((filters or {}).items()))) - if key not in _cache: - _cache[key] = fn(filters, *args, **kwargs) - return _cache[key] + if key not in cache: + cache[key] = fn(filters, *args, **kwargs) + return cache[key] return wrapper @@ -50,7 +60,7 @@ def _cached(fn): def execute(filters=None): filters = filters or {} validate_company_region(filters) - _cache.clear() + _get_cache().clear() columns = get_columns() data = get_data(filters) return columns, data From a8b6bcacc57ff1258922e4b1d3eb037828c452fa Mon Sep 17 00:00:00 2001 From: Bibin <17405044+bibinqcs@users.noreply.github.com> Date: Sun, 21 Jun 2026 16:43:13 +0000 Subject: [PATCH 6/7] fix(FTA Audit File): block regeneration from Generated state The JS button only renders the Generate/Retry action for Draft and Error; the REST endpoint, however, still let an authenticated caller silently overwrite the attached CSV on a Generated FAF. Tighten the server-side guard to match the UI lifecycle so the destructive action has to be explicit (delete and create a new doc to regenerate). --- erpnext/regional/doctype/fta_audit_file/fta_audit_file.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py index e228b67d00f..e0bcf2b1635 100644 --- a/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py +++ b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py @@ -121,8 +121,11 @@ class FTAAuditFile(Document): current_status = frappe.db.get_value(self.doctype, self.name, "status", for_update=True) if current_status in IN_FLIGHT_STATUSES: frappe.throw(_("FAF generation is already {0} for this document.").format(current_status)) - if current_status == "Submitted": - frappe.throw(_("Cannot regenerate a Submitted FAF.")) + if current_status in ("Generated", "Submitted"): + # UI hides the Generate/Retry button for Generated and Submitted; + # enforce the same lifecycle on the REST endpoint so a direct + # call cannot silently overwrite the attached CSV. + frappe.throw(_("FAF is already {0}; create a new document to regenerate.").format(current_status)) self.status = "Queued" self.generation_log = "" From d0988dc32c1034793530d843912a86fce331f4fa Mon Sep 17 00:00:00 2001 From: Bibin <17405044+bibinqcs@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:25:19 +0000 Subject: [PATCH 7/7] fix(UAE VAT 201): bypass helper cache in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit frappe.local is request-scoped, not test-scoped — it survives across unit-test methods. Two tests calling get_standard_rated_ expenses_total({"company": "_Test Company UAE VAT"}) hit the same cache key, so the second test (foreign-currency PI, expected 917.5) was seeing 250 carried over from the first. Short-circuit @_cached on frappe.flags.in_test so each test method queries fresh. Production callers run one execute() per request and have the cache cleared at the top of that call, so the optimisation still applies there. --- erpnext/regional/report/uae_vat_201/uae_vat_201.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/erpnext/regional/report/uae_vat_201/uae_vat_201.py b/erpnext/regional/report/uae_vat_201/uae_vat_201.py index e7979283f0f..1459602ac02 100644 --- a/erpnext/regional/report/uae_vat_201/uae_vat_201.py +++ b/erpnext/regional/report/uae_vat_201/uae_vat_201.py @@ -48,6 +48,14 @@ def _drill_down_link(text, filters, **extra): def _cached(fn): def wrapper(filters, *args, **kwargs): + # ``frappe.local`` survives across unit-test methods (it is request + # scoped, not test scoped). Two tests that call the same helper with + # equivalent filter dicts would otherwise share a cached value from + # the first test's data set. Bypass the cache in tests so each + # call hits the DB; production callers (one execute() per HTTP + # request, cache cleared at its start) still see the optimisation. + if frappe.flags.in_test: + return fn(filters, *args, **kwargs) cache = _get_cache() key = (fn.__name__, tuple(sorted((filters or {}).items()))) if key not in cache: