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") %}

- - - - - {% 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..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",