mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-18 17:08:42 +00:00
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:
@@ -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
|
||||
|
||||
10
erpnext/patches/v16_0/add_arabic_company_name_field.py
Normal file
10
erpnext/patches/v16_0/add_arabic_company_name_field.py
Normal 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()
|
||||
0
erpnext/regional/doctype/fta_audit_file/__init__.py
Normal file
0
erpnext/regional/doctype/fta_audit_file/__init__.py
Normal file
123
erpnext/regional/doctype/fta_audit_file/fta_audit_file.js
Normal file
123
erpnext/regional/doctype/fta_audit_file/fta_audit_file.js
Normal 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);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
221
erpnext/regional/doctype/fta_audit_file/fta_audit_file.json
Normal file
221
erpnext/regional/doctype/fta_audit_file/fta_audit_file.json
Normal 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
|
||||
}
|
||||
659
erpnext/regional/doctype/fta_audit_file/fta_audit_file.py
Normal file
659
erpnext/regional/doctype/fta_audit_file/fta_audit_file.py
Normal 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"
|
||||
258
erpnext/regional/doctype/fta_audit_file/test_fta_audit_file.py
Normal file
258
erpnext/regional/doctype/fta_audit_file/test_fta_audit_file.py
Normal 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()
|
||||
@@ -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>
|
||||
|
||||
@@ -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>`);
|
||||
|
||||
@@ -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")
|
||||
|
||||
73
erpnext/regional/report/uae_vat_register/uae_vat_register.js
Normal file
73
erpnext/regional/report/uae_vat_register/uae_vat_register.js
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
216
erpnext/regional/report/uae_vat_register/uae_vat_register.py
Normal file
216
erpnext/regional/report/uae_vat_register/uae_vat_register.py
Normal 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 ""
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user