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] 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",