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..100bf5502a4
--- /dev/null
+++ b/erpnext/patches/v16_0/add_arabic_company_name_field.py
@@ -0,0 +1,21 @@
+import frappe
+from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
+
+
+def execute():
+ if not frappe.db.exists("Company", {"country": "United Arab Emirates"}):
+ return
+
+ 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/__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..233819b190d
--- /dev/null
+++ b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.js
@@ -0,0 +1,126 @@
+// 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) {
+ // 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(
+ __(frm.doc.status === "Error" ? "Retry FAF Generation" : "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..061b50e219f
--- /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",
+ "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..e0bcf2b1635
--- /dev/null
+++ b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py
@@ -0,0 +1,807 @@
+# 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. 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
+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"
+
+# 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.
+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):
+ 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
+ )
+ )
+
+ 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.
+
+ 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.
+ """
+ # `@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)
+ if current_status in IN_FLIGHT_STATUSES:
+ frappe.throw(_("FAF generation is already {0} for this document.").format(current_status))
+ 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 = ""
+ 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)."""
+ self.check_permission("write")
+ 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:
+ # 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(),
+ )
+ 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}")
+
+ 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_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)
+
+ total_purchase_company = 0.0
+ total_vat_company = 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, []):
+ # 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(
+ [
+ _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_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_company += net_company
+ total_vat_company += vat_company
+ line_count += 1
+
+ writer.writerow(
+ [
+ "PurcDataEnd",
+ _money(total_purchase_company),
+ _money(total_vat_company),
+ 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_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)
+
+ total_supply_company = 0.0
+ total_vat_company = 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, []):
+ # 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"
+ elif item.is_exempt:
+ tax_code = "EX"
+ else:
+ tax_code = _resolve_tax_code(item.item_tax_template, inv.posting_date, tax_code_bands)
+
+ 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_company),
+ _money(vat_company),
+ tax_code,
+ _clean(customer_country),
+ fcy_code,
+ _money(net_fcy),
+ _money(vat_fcy),
+ ]
+ )
+ total_supply_company += net_company
+ total_vat_company += vat_company
+ line_count += 1
+
+ writer.writerow(
+ [
+ "SuppDataEnd",
+ _money(total_supply_company),
+ _money(total_vat_company),
+ line_count,
+ ]
+ )
+ return line_count
+
+ def _write_gl_listing(self, writer):
+ """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,
+ }
+
+ # 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:
+ 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",
+ "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
+ start = 0
+
+ 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,
+ )
+ 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(
+ [
+ "GLDataEnd",
+ _money(total_debit),
+ _money(total_credit),
+ count,
+ company_currency,
+ ]
+ )
+ 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}`` 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 {}
+
+ 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)
+ .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 = {}
+ 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 _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.
+
+ 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"
+
+ 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/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..2754c53cbba
--- /dev/null
+++ b/erpnext/regional/doctype/fta_audit_file/test_fta_audit_file.py
@@ -0,0 +1,238 @@
+# 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_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")
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") %}
-
- | {%= report_columns[0].label %} |
- {%= report_columns[1].label %} |
-
- {% 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) %}
- {% } %}
-
- {% } %}
+
+ | {%= report_columns[0].label %} |
+ {%= report_columns[1].label %} |
+ {%= report_columns[2].label %} |
+ {%= report_columns[3].label %} |
+
+
+ {% 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) %}
+ {% } %}
+
+ {% } %}
+
+ {% } %}
{% } %}
-{%= __("VAT on Expenses and All Other Inputs") %}
+{%= __("VAT on Expenses and All Other Inputs") %}
-
+
- | {%= report_columns[0].label %} |
- {%= report_columns[1].label %} |
-
- {% 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) %}
- {% } %}
-
- {% } %}
+
+ | {%= report_columns[0].label %} |
+ {%= report_columns[1].label %} |
+ {%= report_columns[2].label %} |
+ {%= report_columns[3].label %} |
+
+
+ {% 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) %}
+ {% } %}
+
+ {% } %}
+
+ {% } %}
+ {% } %}
+
+
+
+{%= __("Net VAT Due") %}
+
+
+
+
+ | {%= report_columns[0].label %} |
+ {%= report_columns[1].label %} |
+ {%= report_columns[3].label %} |
+
+
+
+ {% for (let j=20; j<23; j++) { %}
+ {% var row = data[j]; %}
+ {% if (row) { %}
+
+ | {%= 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..1459602ac02 100644
--- a/erpnext/regional/report/uae_vat_201/uae_vat_201.py
+++ b/erpnext/regional/report/uae_vat_201/uae_vat_201.py
@@ -2,17 +2,75 @@
# 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
+# 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):
+ """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):
+ # ``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:
+ cache[key] = fn(filters, *args, **kwargs)
+ return cache[key]
+
+ return wrapper
+
def execute(filters=None):
+ filters = filters or {}
validate_company_region(filters)
+ _get_cache().clear()
columns = get_columns()
- data, emirates, amounts_by_emirate = get_data(filters)
+ data = get_data(filters)
return columns, data
@@ -48,23 +106,86 @@ 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)
+
+ 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 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 key == "reverse_charge_supplies":
+ new_legend = _drill_down_link(legend, filters, doc_type="Purchase Invoice", reverse_charge="Y")
+ elif key == "zero_rated":
+ new_legend = _drill_down_link(legend, filters, doc_type="Sales Invoice", category="Zero Rated")
+ elif key == "exempt_supplies":
+ new_legend = _drill_down_link(legend, filters, doc_type="Sales Invoice", category="Exempt Rated")
+ elif key == "standard_rated_expenses":
+ new_legend = _drill_down_link(legend, filters, doc_type="Purchase Invoice")
+
+ 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"), "", "")
- 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(
@@ -73,15 +194,48 @@ 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,
+ "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 +252,22 @@ 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)
+ 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)
+ v_amount.append(amounts_by_emirate[emirate].get("raw_vat_amount") or 0)
else:
append_data(
data,
@@ -115,8 +275,9 @@ 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
+ return amounts_by_emirate, s_amount, v_amount
def append_vat_on_expenses(data, filters):
@@ -128,6 +289,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,
@@ -137,30 +299,103 @@ def append_vat_on_expenses(data, filters):
frappe.format(get_reverse_charge_recoverable_tax(filters), "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})
+ 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, 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):
+ """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")
- 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():
@@ -168,245 +403,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"]])
- if filters.get("from_date"):
- query_filters.append(["posting_date", "<=", filters["to_date"]])
- return query_filters
+ query = query.where(table.posting_date >= filters["from_date"])
+ if filters.get("to_date"):
+ 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_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_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_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_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/__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..534e914936c
--- /dev/null
+++ b/erpnext/regional/report/uae_vat_register/uae_vat_register.py
@@ -0,0 +1,197 @@
+# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+
+import frappe
+from frappe import _
+from frappe.query_builder.functions import Coalesce, Sum
+
+
+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_rows(filters, is_sales=True)
+ if doc_type == "Purchase Invoice":
+ return _fetch_rows(filters, is_sales=False)
+ return []
+
+
+def _fetch_rows(filters, is_sales):
+ """Build the VAT register query for either Sales or Purchase Invoices.
+
+ 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"))
+
+ 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)
+ )
+
+ 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 _apply_period_filters(query, parent, filters):
+ if filters.get("company"):
+ query = query.where(parent.company == filters["company"])
+ if filters.get("from_date"):
+ query = query.where(parent.posting_date >= filters["from_date"])
+ if filters.get("to_date"):
+ query = query.where(parent.posting_date <= filters["to_date"])
+ return query
+
+
+def _sales_category_criterion(child, category):
+ """Translate the ``category`` filter into a Sales Invoice Item criterion."""
+ if category == "Standard":
+ return (child.is_zero_rated != 1) & (child.is_exempt != 1)
+ if category == "Zero Rated":
+ return child.is_zero_rated == 1
+ if category == "Exempt Rated":
+ return child.is_exempt == 1
+ return None
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",