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.
This commit is contained in:
Bibin
2026-04-28 10:28:45 +00:00
parent 0ff0343588
commit 54d3200efa
15 changed files with 1986 additions and 74 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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);
}
}
},
});

View File

@@ -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
}

View File

@@ -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"

View File

@@ -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()

View File

@@ -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);
%}
<style>
.print-format {
padding: 10mm;
font-size: 8.0pt !important;
font-family: Tahoma, sans-serif;
}
.print-format {
padding: 10mm;
font-size: 8pt !important;
font-family: Tahoma, sans-serif;
}
.print-format th.col-no { width: 8%; }
.print-format th.col-legend { width: 58%; }
.print-format th.col-amount,
.print-format th.col-vat { width: 17%; }
.print-format th.nvd-legend { width: 75%; }
.print-format td.num,
.print-format th.num { text-align: right; }
</style>
<h1 style="margin-top:0; text-align: center;">{%= __(report.report_name) %}</h1>
<h1 style="margin-top:0; text-align: center;">{%= __(report.report_name) %}</h1>
<h3 style="margin-top:0; font-weight:500">{%= __("VAT on Sales and All Other Outputs") %}</h2>
<h3 style="margin-top:0; font-weight:500">{%= __("VAT on Sales and All Other Outputs") %}</h3>
<table class="table table-bordered">
<thead>
<th style="width: 13">{%= report_columns[0].label %}</th>
<th style="width: {%= 100 - (report_columns.length - 1) * 13%}%">{%= report_columns[1].label %}</th>
{% for (let i=2; i<report_columns.length; i++) { %}
<th style="width: 13">{%= report_columns[i].label %}</th>
{% } %}
</thead>
<tbody>
{% for (let j=1; j<12; j++) { %}
{%
var row = data[j];
%}
<tr >
{% for (let i=0; i<report_columns.length; i++) { %}
<td >
{% const fieldname = report_columns[i].fieldname; %}
{% if (!is_null(row[fieldname])) { %}
{%= frappe.format(row[fieldname], report_columns[i], {}, row) %}
{% } %}
</td>
{% } %}
<tr>
<th class="col-no">{%= report_columns[0].label %}</th>
<th class="col-legend">{%= report_columns[1].label %}</th>
<th class="col-amount num">{%= report_columns[2].label %}</th>
<th class="col-vat num">{%= report_columns[3].label %}</th>
</tr>
</thead>
<tbody>
{% for (let j=1; j<13; j++) { %}
{% var row = data[j]; %}
{% if (row) { %}
<tr>
{% for (let i=0; i<report_columns.length; i++) { %}
<td class="{%= report_columns[i].fieldtype == 'Currency' ? 'num' : '' %}">
{% const fieldname = report_columns[i].fieldname; %}
{% if (!is_null(row[fieldname])) { %}
{%= frappe.format(row[fieldname], report_columns[i], {}, row) %}
{% } %}
</td>
{% } %}
</tr>
{% } %}
{% } %}
</tbody>
</table>
<h3 style="margin-top:0; font-weight:500">{%= __("VAT on Expenses and All Other Inputs") %}</h2>
<h3 style="margin-top:0; font-weight:500">{%= __("VAT on Expenses and All Other Inputs") %}</h3>
<table class="table table-bordered">
<table class="table table-bordered">
<thead>
<th style="width: 13">{%= report_columns[0].label %}</th>
<th style="width: {%= 100 - (report_columns.length - 1) * 13%}%">{%= report_columns[1].label %}</th>
{% for (let i=2; i<report_columns.length; i++) { %}
<th style="width: 13">{%= report_columns[i].label %}</th>
{% } %}
</thead>
<tbody>
{% for (let j=14; j<data.length; j++) { %}
{%
var row = data[j];
%}
<tr >
{% for (let i=0; i<report_columns.length; i++) { %}
<td >
{% const fieldname = report_columns[i].fieldname; %}
{% if (!is_null(row[fieldname])) { %}
{%= frappe.format(row[fieldname], report_columns[i], {}, row) %}
{% } %}
</td>
{% } %}
<tr>
<th class="col-no">{%= report_columns[0].label %}</th>
<th class="col-legend">{%= report_columns[1].label %}</th>
<th class="col-amount num">{%= report_columns[2].label %}</th>
<th class="col-vat num">{%= report_columns[3].label %}</th>
</tr>
</thead>
<tbody>
{% for (let j=15; j<18; j++) { %}
{% var row = data[j]; %}
{% if (row) { %}
<tr>
{% for (let i=0; i<report_columns.length; i++) { %}
<td class="{%= report_columns[i].fieldtype == 'Currency' ? 'num' : '' %}">
{% const fieldname = report_columns[i].fieldname; %}
{% if (!is_null(row[fieldname])) { %}
{%= frappe.format(row[fieldname], report_columns[i], {}, row) %}
{% } %}
</td>
{% } %}
</tr>
{% } %}
{% } %}
</tbody>
</table>
<h3 style="margin-top:0; font-weight:500">{%= __("Net VAT Due") %}</h3>
<table class="table table-bordered">
<thead>
<tr>
<th class="col-no">{%= report_columns[0].label %}</th>
<th class="nvd-legend">{%= report_columns[1].label %}</th>
<th class="col-vat num">{%= report_columns[3].label %}</th>
</tr>
</thead>
<tbody>
{% for (let j=20; j<23; j++) { %}
{% var row = data[j]; %}
{% if (row) { %}
<tr>
<td>{%= row.no %}</td>
<td>{%= row.legend %}</td>
<td class="num">{%= row.vat_amount %}</td>
</tr>
{% } %}
{% } %}
</tbody>
</table>

View File

@@ -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 = $(`<span>${value}</span>`);

View File

@@ -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"""
<a href= "/app/query-report/UAE%20VAT%20Register?company={filters.get("company")}&from_date={filters.get("from_date")}&to_date={filters.get("to_date")}&doc_type=Sales%20Invoice&vat=Abu%20Dhabi&category=Standard">{data[i].get("legend")}</a>
"""
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"""
<a href= "/app/query-report/UAE%20VAT%20Register?company={filters.get("company")}&from_date={filters.get("from_date")}&to_date={filters.get("to_date")}&doc_type=Sales%20Invoice&vat=Dubai&category=Standard">{name}</a>
"""
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"""
<a href= "/app/query-report/UAE%20VAT%20Register?company={filters.get("company")}&from_date={filters.get("from_date")}&to_date={filters.get("to_date")}&doc_type=Sales%20Invoice&vat=Sharjah&category=Standard">{data[i].get("legend")}</a>
"""
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"""
<a href= "/app/query-report/UAE%20VAT%20Register?company={filters.get("company")}&from_date={filters.get("from_date")}&to_date={filters.get("to_date")}&doc_type=Sales%20Invoice&vat=Ajman&category=Standard">{data[i].get("legend")}</a>
"""
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"""
<a href= "/app/query-report/UAE%20VAT%20Register?company={filters.get("company")}&from_date={filters.get("from_date")}&to_date={filters.get("to_date")}&doc_type=Sales%20Invoice&vat=Umm%20Al%20Quwain&category=Standard">{data[i].get("legend")}</a>
"""
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"""
<a href= "/app/query-report/UAE%20VAT%20Register?company={filters.get("company")}&from_date={filters.get("from_date")}&to_date={filters.get("to_date")}&doc_type=Sales%20Invoice&vat=Ras%20Al%20Khaimah&category=Standard">{data[i].get("legend")}</a>
"""
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"""
<a href= "/app/query-report/UAE%20VAT%20Register?company={filters.get("company")}&from_date={filters.get("from_date")}&to_date={filters.get("to_date")}&doc_type=Sales%20Invoice&vat=Fujairah&category=Standard">{data[i].get("legend")}</a>
"""
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"""
<a href= "/app/query-report/UAE%20VAT%20Register?company={filters.get("company")}&from_date={filters.get("from_date")}&to_date={filters.get("to_date")}&doc_type=Purchase%20Invoice&reverse_charge=Y">{data[i].get("legend")}</a>
"""
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"""
<a href= "/app/query-report/UAE%20VAT%20Register?company={filters.get("company")}&from_date={filters.get("from_date")}&to_date={filters.get("to_date")}&doc_type=Sales%20Invoice&category=Zero%20Rated">{data[i].get("legend")}</a>
"""
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"""
<a href= "/app/query-report/UAE%20VAT%20Register?company={filters.get("company")}&from_date={filters.get("from_date")}&to_date={filters.get("to_date")}&doc_type=Sales%20Invoice&category=Exempt%20Rated">{data[i].get("legend")}</a>
"""
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"""
<a href= "/app/query-report/UAE%20VAT%20Register?company={filters.get("company")}&from_date={filters.get("from_date")}&to_date={filters.get("to_date")}&doc_type=Purchase%20Invoice">{data[i].get("legend")}</a>
"""
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")

View File

@@ -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,
},
],
};

View File

@@ -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
}

View File

@@ -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 ""

View File

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