mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-15 23:48:38 +00:00
Revert "feat: Enhance UAE VAT Reports, UAE FTA Audit File and Add VAT Register"
This commit is contained in:
@@ -489,4 +489,3 @@ 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
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import frappe
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
|
||||
|
||||
def execute():
|
||||
if not frappe.db.exists("Company", {"country": "United Arab Emirates"}):
|
||||
return
|
||||
|
||||
create_custom_fields(
|
||||
{
|
||||
"Company": [
|
||||
{
|
||||
"fieldname": "company_name_in_arabic",
|
||||
"label": "Company Name in Arabic",
|
||||
"fieldtype": "Data",
|
||||
"insert_after": "company_name",
|
||||
}
|
||||
]
|
||||
},
|
||||
ignore_validate=True,
|
||||
)
|
||||
@@ -1,126 +0,0 @@
|
||||
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("FTA Audit File", {
|
||||
refresh: function (frm) {
|
||||
// Generate FAF — available from Draft (first generation) and Error
|
||||
// (retry after a previous attempt failed). Queued/Generating are
|
||||
// blocked by the server guard; Generated/Submitted are intentionally
|
||||
// not re-generable.
|
||||
if (!frm.is_new() && ["Draft", "Error"].includes(frm.doc.status)) {
|
||||
frm.add_custom_button(
|
||||
__(frm.doc.status === "Error" ? "Retry FAF Generation" : "Generate FAF"),
|
||||
function () {
|
||||
frm.trigger("generate_faf");
|
||||
},
|
||||
__("Actions")
|
||||
);
|
||||
}
|
||||
|
||||
// Add Mark as Submitted button for Generated status
|
||||
if (frm.doc.status === "Generated") {
|
||||
frm.add_custom_button(
|
||||
__("Mark as Submitted"),
|
||||
function () {
|
||||
frm.trigger("mark_submitted");
|
||||
},
|
||||
__("Actions")
|
||||
);
|
||||
}
|
||||
|
||||
// Add Download button if file exists
|
||||
if (frm.doc.faf_file) {
|
||||
frm.add_custom_button(
|
||||
__("Download FAF"),
|
||||
function () {
|
||||
window.open(frm.doc.faf_file);
|
||||
},
|
||||
__("Actions")
|
||||
);
|
||||
}
|
||||
|
||||
// Show status indicator
|
||||
frm.trigger("set_status_indicator");
|
||||
},
|
||||
|
||||
generate_faf: function (frm) {
|
||||
frappe.confirm(
|
||||
__("This will generate the FTA Audit File for the selected period. Continue?"),
|
||||
function () {
|
||||
frm.call({
|
||||
doc: frm.doc,
|
||||
method: "generate_faf",
|
||||
freeze: true,
|
||||
freeze_message: __("Queuing FTA Audit File generation..."),
|
||||
}).then((r) => {
|
||||
if (!r.message) return;
|
||||
if (r.message.success) {
|
||||
frappe.show_alert({
|
||||
message: r.message.message,
|
||||
indicator: "green",
|
||||
});
|
||||
} else {
|
||||
frappe.msgprint({
|
||||
title: __("Generation Failed"),
|
||||
message: r.message.message,
|
||||
indicator: "red",
|
||||
});
|
||||
}
|
||||
frm.reload_doc();
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
mark_submitted: function (frm) {
|
||||
frappe.confirm(
|
||||
__("Mark this FAF as submitted to FTA? This action is for record-keeping only."),
|
||||
function () {
|
||||
frm.call({
|
||||
doc: frm.doc,
|
||||
method: "mark_as_submitted",
|
||||
}).then((r) => {
|
||||
if (r.message && r.message.success) {
|
||||
frappe.show_alert({
|
||||
message: r.message.message,
|
||||
indicator: "green",
|
||||
});
|
||||
frm.reload_doc();
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
set_status_indicator: function (frm) {
|
||||
const status_colors = {
|
||||
Draft: "orange",
|
||||
Queued: "yellow",
|
||||
Generating: "blue",
|
||||
Generated: "green",
|
||||
Submitted: "blue",
|
||||
Error: "red",
|
||||
};
|
||||
|
||||
if (frm.doc.status) {
|
||||
frm.page.set_indicator(__(frm.doc.status), status_colors[frm.doc.status] || "gray");
|
||||
}
|
||||
},
|
||||
|
||||
from_date: function (frm) {
|
||||
frm.trigger("validate_dates");
|
||||
},
|
||||
|
||||
to_date: function (frm) {
|
||||
frm.trigger("validate_dates");
|
||||
},
|
||||
|
||||
validate_dates: function (frm) {
|
||||
if (frm.doc.from_date && frm.doc.to_date) {
|
||||
if (frm.doc.from_date > frm.doc.to_date) {
|
||||
frappe.msgprint(__("From Date cannot be after To Date"));
|
||||
frm.set_value("to_date", null);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,221 +0,0 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 0,
|
||||
"autoname": "naming_series:",
|
||||
"beta": 1,
|
||||
"creation": "2025-12-11 00:29:21.891860",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"naming_series",
|
||||
"company",
|
||||
"column_break_1",
|
||||
"from_date",
|
||||
"to_date",
|
||||
"section_break_agent",
|
||||
"tax_agency_name",
|
||||
"tan",
|
||||
"column_break_agent",
|
||||
"tax_agent_name",
|
||||
"taan",
|
||||
"section_break_options",
|
||||
"file_type",
|
||||
"include_opening_balance",
|
||||
"column_break_options_2",
|
||||
"status",
|
||||
"section_break_output",
|
||||
"faf_file",
|
||||
"section_break_logs",
|
||||
"generation_log",
|
||||
"error_message"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "naming_series",
|
||||
"fieldtype": "Select",
|
||||
"label": "Series",
|
||||
"options": "FAF-.YYYY.-",
|
||||
"reqd": 1,
|
||||
"hidden": 1,
|
||||
"default": "FAF-.YYYY.-"
|
||||
},
|
||||
{
|
||||
"fieldname": "company",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Company",
|
||||
"options": "Company",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_1",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "from_date",
|
||||
"fieldtype": "Date",
|
||||
"in_list_view": 1,
|
||||
"label": "From Date",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "to_date",
|
||||
"fieldtype": "Date",
|
||||
"in_list_view": 1,
|
||||
"label": "To Date",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_agent",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Tax Agent (optional)",
|
||||
"description": "Fill in only if the FAF is being filed through a registered Tax Agency or Tax Agent.",
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:!(doc.tax_agency_name || doc.tan || doc.tax_agent_name || doc.taan)"
|
||||
},
|
||||
{
|
||||
"fieldname": "tax_agency_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Tax Agency Name",
|
||||
"length": 100
|
||||
},
|
||||
{
|
||||
"fieldname": "tan",
|
||||
"fieldtype": "Data",
|
||||
"label": "TAN (Tax Agency Number)",
|
||||
"length": 20
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_agent",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "tax_agent_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Tax Agent Name",
|
||||
"length": 100
|
||||
},
|
||||
{
|
||||
"fieldname": "taan",
|
||||
"fieldtype": "Data",
|
||||
"label": "TAAN (Tax Agent Approval Number)",
|
||||
"length": 20
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_options",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Options"
|
||||
},
|
||||
{
|
||||
"fieldname": "file_type",
|
||||
"fieldtype": "Select",
|
||||
"label": "File Type",
|
||||
"options": "VAT",
|
||||
"default": "VAT",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "include_opening_balance",
|
||||
"fieldtype": "Check",
|
||||
"label": "Include Opening Balance in GL",
|
||||
"description": "Carry each account's pre-period balance forward into the General Ledger Balance column. Off by default (period-only running balance), matching the lighter interpretation used by Microsoft Dynamics 365 Finance.",
|
||||
"default": "0"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_options_2",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"label": "Status",
|
||||
"options": "Draft\nQueued\nGenerating\nGenerated\nSubmitted\nError",
|
||||
"default": "Draft",
|
||||
"in_list_view": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_output",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Generated File",
|
||||
"depends_on": "eval:doc.status === 'Generated' || doc.status === 'Submitted'"
|
||||
},
|
||||
{
|
||||
"fieldname": "faf_file",
|
||||
"fieldtype": "Attach",
|
||||
"label": "FAF File",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_logs",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Generation Logs",
|
||||
"collapsible": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "generation_log",
|
||||
"fieldtype": "Long Text",
|
||||
"label": "Generation Log",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "error_message",
|
||||
"fieldtype": "Long Text",
|
||||
"label": "Error Message",
|
||||
"read_only": 1,
|
||||
"depends_on": "eval:doc.status === 'Error'"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-12-11 00:29:21.891860",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Regional",
|
||||
"name": "FTA Audit File",
|
||||
"naming_rule": "By \"Naming Series\" field",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "company",
|
||||
"track_changes": 1
|
||||
}
|
||||
@@ -1,807 +0,0 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
"""
|
||||
FTA Audit File DocType Controller
|
||||
|
||||
Owns generation of FTA Audit Files (FAF) for UAE VAT compliance per the
|
||||
FTA "Requirements Document for Tax Accounting Software" (October 2017),
|
||||
Appendix 5.
|
||||
|
||||
A conformant VAT FAF contains four CSV tables in this order, each
|
||||
delimited by an explicit start/end marker row:
|
||||
|
||||
1. Company Information (CompInfoStart .. CompInfoEnd)
|
||||
2. Purchase Listing (PurcDataStart .. PurcDataEnd)
|
||||
3. Supply Listing (SuppDataStart .. SuppDataEnd)
|
||||
4. General Ledger (GLDataStart .. GLDataEnd)
|
||||
|
||||
The footer of each transactional table carries running totals plus a
|
||||
transaction count. Primary amount columns are in the company's accounting
|
||||
currency (typically AED for a UAE-registered entity); foreign-currency
|
||||
mirrors are emitted alongside when the source invoice is in a different
|
||||
currency.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import flt, getdate, today
|
||||
from frappe.utils.file_manager import save_file
|
||||
|
||||
FAF_VERSION = "FAFv1.0.0"
|
||||
DEFAULT_COUNTRY = "United Arab Emirates"
|
||||
DEFAULT_DATE = "31-12-9999"
|
||||
PRODUCT_VERSION = "ERPNext"
|
||||
|
||||
# Number of GL Entry rows read into memory per batch when streaming the
|
||||
# General Ledger section. Large UAE companies routinely produce hundreds
|
||||
# of thousands of GL entries per year; reading them all into a single list
|
||||
# would push the background worker over its memory limit.
|
||||
GL_PAGE_SIZE = 5000
|
||||
|
||||
# Inputs that define what the FAF file represents. Once the file has been
|
||||
# Generated or Submitted, changing any of these would silently desync the
|
||||
# attached CSV from the form, so we lock them.
|
||||
LOCKED_INPUT_FIELDS = (
|
||||
"company",
|
||||
"from_date",
|
||||
"to_date",
|
||||
"file_type",
|
||||
"include_opening_balance",
|
||||
"tax_agency_name",
|
||||
"tan",
|
||||
"tax_agent_name",
|
||||
"taan",
|
||||
)
|
||||
LOCKED_STATUSES = ("Generated", "Submitted")
|
||||
IN_FLIGHT_STATUSES = ("Queued", "Generating")
|
||||
|
||||
|
||||
class FTAAuditFile(Document):
|
||||
def validate(self):
|
||||
if getdate(self.from_date) > getdate(self.to_date):
|
||||
frappe.throw(_("From Date cannot be after To Date"))
|
||||
|
||||
if not frappe.db.get_value("Company", self.company, "tax_id"):
|
||||
frappe.throw(
|
||||
_("Company {0} does not have a Tax ID (TRN). Please set the Tax ID in Company.").format(
|
||||
self.company
|
||||
)
|
||||
)
|
||||
|
||||
self._guard_locked_fields()
|
||||
|
||||
if self.status != "Error":
|
||||
self.error_message = None
|
||||
|
||||
def _guard_locked_fields(self):
|
||||
"""Block edits to FAF inputs once the file is Generated or Submitted.
|
||||
|
||||
The status field alone is read-only in the UI, but a user with write
|
||||
permission can still patch fields via REST or scripts; this enforces
|
||||
immutability server-side so the attached CSV always matches the form.
|
||||
"""
|
||||
if self.is_new():
|
||||
return
|
||||
|
||||
previous_status = self.get_db_value("status")
|
||||
if previous_status not in LOCKED_STATUSES:
|
||||
return
|
||||
|
||||
changed = [f for f in LOCKED_INPUT_FIELDS if self.has_value_changed(f)]
|
||||
if changed:
|
||||
frappe.throw(
|
||||
_("Cannot modify {0} after the FAF has been {1}.").format(", ".join(changed), previous_status)
|
||||
)
|
||||
|
||||
@frappe.whitelist()
|
||||
def generate_faf(self):
|
||||
"""Queue FAF generation as a background job.
|
||||
|
||||
Returns immediately with status ``Queued``. The actual generation
|
||||
runs in ``_run_generation`` on the ``long`` queue (Frappe's
|
||||
``enqueue_doc`` re-fetches a fresh doc inside the worker) and
|
||||
updates ``status``, ``faf_file``, ``generation_log``, and
|
||||
``error_message`` when complete.
|
||||
|
||||
Under ``frappe.flags.in_test`` the job runs synchronously so tests
|
||||
can assert on the post-generation state without polling.
|
||||
"""
|
||||
# `@frappe.whitelist()` gates network access but not document write
|
||||
# permission; without this explicit check, a user with read-only
|
||||
# access could still trigger generation via REST.
|
||||
self.check_permission("write")
|
||||
|
||||
# Re-read status from DB so two concurrent button clicks can't both
|
||||
# enqueue a job — the second one sees Queued/Generating and bails.
|
||||
current_status = frappe.db.get_value(self.doctype, self.name, "status", for_update=True)
|
||||
if current_status in IN_FLIGHT_STATUSES:
|
||||
frappe.throw(_("FAF generation is already {0} for this document.").format(current_status))
|
||||
if current_status in ("Generated", "Submitted"):
|
||||
# UI hides the Generate/Retry button for Generated and Submitted;
|
||||
# enforce the same lifecycle on the REST endpoint so a direct
|
||||
# call cannot silently overwrite the attached CSV.
|
||||
frappe.throw(_("FAF is already {0}; create a new document to regenerate.").format(current_status))
|
||||
|
||||
self.status = "Queued"
|
||||
self.generation_log = ""
|
||||
self.error_message = None
|
||||
self.save()
|
||||
|
||||
frappe.enqueue_doc(
|
||||
self.doctype,
|
||||
self.name,
|
||||
"_run_generation",
|
||||
queue="long",
|
||||
timeout=1500,
|
||||
enqueue_after_commit=True,
|
||||
now=bool(frappe.flags.in_test),
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": _("FAF generation has been queued. The status will update when complete."),
|
||||
"docname": self.name,
|
||||
"status": self.status,
|
||||
}
|
||||
|
||||
@frappe.whitelist()
|
||||
def mark_as_submitted(self):
|
||||
"""Mark the FAF as submitted to the FTA portal (manual record)."""
|
||||
self.check_permission("write")
|
||||
if self.status != "Generated":
|
||||
frappe.throw(_("Only Generated files can be marked as Submitted"))
|
||||
self.status = "Submitted"
|
||||
self.save()
|
||||
return {"success": True, "message": _("FAF marked as submitted")}
|
||||
|
||||
def _run_generation(self):
|
||||
"""Background entry point. Invoked via ``frappe.enqueue_doc`` from
|
||||
``generate_faf`` (or synchronously under ``frappe.flags.in_test``).
|
||||
|
||||
Broad ``except`` is intentional: a long-running batch op records
|
||||
the failure on the doc itself (status/error_message/log) so the
|
||||
user sees what went wrong without the request 500'ing. The
|
||||
exception is re-raised so the queue marks the job as failed and
|
||||
the traceback is written to the Error Log.
|
||||
"""
|
||||
try:
|
||||
self.status = "Generating"
|
||||
self.save()
|
||||
|
||||
result = self._build_faf()
|
||||
self.faf_file = result["file_url"]
|
||||
self.generation_log = result["log"]
|
||||
self.status = "Generated"
|
||||
self.save()
|
||||
except Exception as e:
|
||||
try:
|
||||
err_doc = frappe.get_doc(self.doctype, self.name)
|
||||
err_doc.status = "Error"
|
||||
err_doc.error_message = str(e)
|
||||
err_doc.generation_log = (err_doc.generation_log or "") + f"\n\nError: {e}"
|
||||
err_doc.save()
|
||||
except Exception:
|
||||
# Don't lose the original failure if persisting the Error
|
||||
# state itself fails (e.g. row lock, validation regression);
|
||||
# log the secondary failure with context, then re-raise the
|
||||
# original ``e`` below so the job is still marked failed.
|
||||
frappe.log_error(
|
||||
title=_("FAF Error-state persistence failed"),
|
||||
message=f"{self.doctype} {self.name}\n\n{frappe.get_traceback()}",
|
||||
)
|
||||
frappe.log_error(
|
||||
title=_("FAF Generation Error"),
|
||||
message=frappe.get_traceback(),
|
||||
)
|
||||
raise
|
||||
|
||||
def _build_faf(self):
|
||||
"""Build the FAF CSV per Appendix 5 and attach it to this document."""
|
||||
log_entries = []
|
||||
|
||||
def log(msg):
|
||||
log_entries.append(msg)
|
||||
|
||||
log(f"Starting FAF generation for {self.company}")
|
||||
log(f"Period: {self.from_date} to {self.to_date}")
|
||||
log(f"File Type: {self.file_type}")
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
self._write_company_info(writer)
|
||||
log("Company Information written")
|
||||
|
||||
purchase_count = self._write_purchase_listing(writer)
|
||||
log(f"Purchase Listing written: {purchase_count} line items")
|
||||
|
||||
supply_count = self._write_supply_listing(writer)
|
||||
log(f"Supply Listing written: {supply_count} line items")
|
||||
|
||||
gl_count = self._write_gl_listing(writer)
|
||||
log(f"General Ledger written: {gl_count} entries")
|
||||
|
||||
csv_content = output.getvalue()
|
||||
output.close()
|
||||
|
||||
file_name = f"FAF_{self.company}_{self.from_date}_to_{self.to_date}.csv".replace(" ", "_")
|
||||
file_doc = save_file(
|
||||
fname=file_name,
|
||||
content=csv_content.encode("utf-8"),
|
||||
dt="FTA Audit File",
|
||||
dn=self.name,
|
||||
is_private=1,
|
||||
)
|
||||
|
||||
log(f"FAF file generated: {file_name}")
|
||||
log("Generation completed successfully")
|
||||
|
||||
return {"file_url": file_doc.file_url, "log": "\n".join(log_entries)}
|
||||
|
||||
def _write_company_info(self, writer):
|
||||
"""Emit ``CompInfoStart`` + body row + ``CompInfoEnd`` per Appendix 5."""
|
||||
writer.writerow(["CompInfoStart"])
|
||||
|
||||
info = (
|
||||
frappe.db.get_value(
|
||||
"Company",
|
||||
self.company,
|
||||
["company_name", "company_name_in_arabic", "tax_id"],
|
||||
as_dict=True,
|
||||
)
|
||||
or {}
|
||||
)
|
||||
|
||||
writer.writerow(
|
||||
[
|
||||
_clean(info.get("company_name") or self.company),
|
||||
_clean(info.get("company_name_in_arabic") or ""),
|
||||
info.get("tax_id") or "",
|
||||
_clean(self.tax_agency_name or ""),
|
||||
_clean(self.tan or ""),
|
||||
_clean(self.tax_agent_name or ""),
|
||||
_clean(self.taan or ""),
|
||||
_format_date(self.from_date),
|
||||
_format_date(self.to_date),
|
||||
_format_date(today()),
|
||||
PRODUCT_VERSION,
|
||||
FAF_VERSION,
|
||||
]
|
||||
)
|
||||
|
||||
writer.writerow(["CompInfoEnd"])
|
||||
|
||||
def _write_purchase_listing(self, writer):
|
||||
"""Emit Purchase Listing per Appendix 5 with end-of-table totals row."""
|
||||
writer.writerow(["PurcDataStart"])
|
||||
|
||||
invoices = frappe.get_all(
|
||||
"Purchase Invoice",
|
||||
filters={
|
||||
"company": self.company,
|
||||
"posting_date": ["between", [self.from_date, self.to_date]],
|
||||
"docstatus": 1,
|
||||
},
|
||||
fields=[
|
||||
"name",
|
||||
"supplier",
|
||||
"supplier_name",
|
||||
"posting_date",
|
||||
"permit_no",
|
||||
"currency",
|
||||
"conversion_rate",
|
||||
],
|
||||
order_by="posting_date asc, name asc",
|
||||
)
|
||||
if not invoices:
|
||||
writer.writerow(["PurcDataEnd", _money(0), _money(0), 0])
|
||||
return 0
|
||||
|
||||
invoice_names = [inv.name for inv in invoices]
|
||||
supplier_names = list({inv.supplier for inv in invoices if inv.supplier})
|
||||
|
||||
supplier_trn_map = _bulk_party_field("Supplier", supplier_names, "tax_id")
|
||||
items_by_invoice = _bulk_invoice_items(
|
||||
"Purchase Invoice Item",
|
||||
invoice_names,
|
||||
[
|
||||
"parent",
|
||||
"idx",
|
||||
"item_name",
|
||||
"description",
|
||||
"base_net_amount",
|
||||
"net_amount",
|
||||
"tax_amount",
|
||||
"item_tax_template",
|
||||
],
|
||||
)
|
||||
tax_code_bands = _bulk_tax_code_bands(
|
||||
{
|
||||
item.item_tax_template
|
||||
for items in items_by_invoice.values()
|
||||
for item in items
|
||||
if item.item_tax_template
|
||||
}
|
||||
)
|
||||
|
||||
company_currency = _company_currency(self.company)
|
||||
|
||||
total_purchase_company = 0.0
|
||||
total_vat_company = 0.0
|
||||
line_count = 0
|
||||
|
||||
for inv in invoices:
|
||||
supplier_trn = supplier_trn_map.get(inv.supplier, "")
|
||||
fcy_code, fcy_factor = _fcy_for_invoice(inv.currency, inv.conversion_rate, company_currency)
|
||||
|
||||
for item in items_by_invoice.get(inv.name, []):
|
||||
# base_net_amount is company-currency; tax_amount is a UAE
|
||||
# custom field with options="currency" and therefore stored
|
||||
# in the document's invoice currency. Multiply by the
|
||||
# conversion rate to land in company currency.
|
||||
net_company = flt(item.base_net_amount, 2)
|
||||
vat_invoice = flt(item.tax_amount or 0, 2)
|
||||
vat_company = flt(vat_invoice * fcy_factor, 2)
|
||||
net_fcy = flt(item.net_amount or 0, 2) if fcy_code != "XXX" else 0.0
|
||||
vat_fcy = vat_invoice if fcy_code != "XXX" else 0.0
|
||||
|
||||
writer.writerow(
|
||||
[
|
||||
_clean(inv.supplier_name),
|
||||
supplier_trn,
|
||||
_format_date(inv.posting_date),
|
||||
inv.name,
|
||||
inv.permit_no or "",
|
||||
item.idx,
|
||||
_clean(item.description or item.item_name or ""),
|
||||
_money(net_company),
|
||||
_money(vat_company),
|
||||
_resolve_tax_code(item.item_tax_template, inv.posting_date, tax_code_bands),
|
||||
fcy_code,
|
||||
_money(net_fcy),
|
||||
_money(vat_fcy),
|
||||
]
|
||||
)
|
||||
total_purchase_company += net_company
|
||||
total_vat_company += vat_company
|
||||
line_count += 1
|
||||
|
||||
writer.writerow(
|
||||
[
|
||||
"PurcDataEnd",
|
||||
_money(total_purchase_company),
|
||||
_money(total_vat_company),
|
||||
line_count,
|
||||
]
|
||||
)
|
||||
return line_count
|
||||
|
||||
def _write_supply_listing(self, writer):
|
||||
"""Emit Supply Listing per Appendix 5 with end-of-table totals row."""
|
||||
writer.writerow(["SuppDataStart"])
|
||||
|
||||
invoices = frappe.get_all(
|
||||
"Sales Invoice",
|
||||
filters={
|
||||
"company": self.company,
|
||||
"posting_date": ["between", [self.from_date, self.to_date]],
|
||||
"docstatus": 1,
|
||||
},
|
||||
fields=[
|
||||
"name",
|
||||
"customer",
|
||||
"customer_name",
|
||||
"posting_date",
|
||||
"currency",
|
||||
"conversion_rate",
|
||||
],
|
||||
order_by="posting_date asc, name asc",
|
||||
)
|
||||
if not invoices:
|
||||
writer.writerow(["SuppDataEnd", _money(0), _money(0), 0])
|
||||
return 0
|
||||
|
||||
invoice_names = [inv.name for inv in invoices]
|
||||
customer_names = list({inv.customer for inv in invoices if inv.customer})
|
||||
|
||||
customer_trn_map = _bulk_party_field("Customer", customer_names, "tax_id")
|
||||
customer_country_map = _bulk_party_country("Customer", customer_names)
|
||||
items_by_invoice = _bulk_invoice_items(
|
||||
"Sales Invoice Item",
|
||||
invoice_names,
|
||||
[
|
||||
"parent",
|
||||
"idx",
|
||||
"item_name",
|
||||
"description",
|
||||
"base_net_amount",
|
||||
"net_amount",
|
||||
"tax_amount",
|
||||
"item_tax_template",
|
||||
"is_zero_rated",
|
||||
"is_exempt",
|
||||
],
|
||||
)
|
||||
tax_code_bands = _bulk_tax_code_bands(
|
||||
{
|
||||
item.item_tax_template
|
||||
for items in items_by_invoice.values()
|
||||
for item in items
|
||||
if item.item_tax_template
|
||||
}
|
||||
)
|
||||
|
||||
company_currency = _company_currency(self.company)
|
||||
|
||||
total_supply_company = 0.0
|
||||
total_vat_company = 0.0
|
||||
line_count = 0
|
||||
|
||||
for inv in invoices:
|
||||
customer_trn = customer_trn_map.get(inv.customer, "")
|
||||
customer_country = customer_country_map.get(inv.customer) or DEFAULT_COUNTRY
|
||||
fcy_code, fcy_factor = _fcy_for_invoice(inv.currency, inv.conversion_rate, company_currency)
|
||||
|
||||
for item in items_by_invoice.get(inv.name, []):
|
||||
# See _write_purchase_listing for the currency convention:
|
||||
# tax_amount is invoice-currency, base_net_amount is
|
||||
# company-currency, and fcy_factor converts invoice → company.
|
||||
net_company = flt(item.base_net_amount, 2)
|
||||
vat_invoice = flt(item.tax_amount or 0, 2)
|
||||
vat_company = flt(vat_invoice * fcy_factor, 2)
|
||||
net_fcy = flt(item.net_amount or 0, 2) if fcy_code != "XXX" else 0.0
|
||||
vat_fcy = vat_invoice if fcy_code != "XXX" else 0.0
|
||||
|
||||
if item.is_zero_rated:
|
||||
tax_code = "ZR"
|
||||
elif item.is_exempt:
|
||||
tax_code = "EX"
|
||||
else:
|
||||
tax_code = _resolve_tax_code(item.item_tax_template, inv.posting_date, tax_code_bands)
|
||||
|
||||
writer.writerow(
|
||||
[
|
||||
_clean(inv.customer_name),
|
||||
customer_trn,
|
||||
_format_date(inv.posting_date),
|
||||
inv.name,
|
||||
item.idx,
|
||||
_clean(item.description or item.item_name or ""),
|
||||
_money(net_company),
|
||||
_money(vat_company),
|
||||
tax_code,
|
||||
_clean(customer_country),
|
||||
fcy_code,
|
||||
_money(net_fcy),
|
||||
_money(vat_fcy),
|
||||
]
|
||||
)
|
||||
total_supply_company += net_company
|
||||
total_vat_company += vat_company
|
||||
line_count += 1
|
||||
|
||||
writer.writerow(
|
||||
[
|
||||
"SuppDataEnd",
|
||||
_money(total_supply_company),
|
||||
_money(total_vat_company),
|
||||
line_count,
|
||||
]
|
||||
)
|
||||
return line_count
|
||||
|
||||
def _write_gl_listing(self, writer):
|
||||
"""Emit General Ledger per Appendix 5 with end-of-table totals row.
|
||||
|
||||
GL Entry rows are streamed in pages of ``GL_PAGE_SIZE`` to keep
|
||||
memory bounded for multi-year exports on large companies; the
|
||||
running-balance, account-name, and totals state survives across
|
||||
pages so the output is identical to a single-fetch implementation.
|
||||
"""
|
||||
writer.writerow(["GLDataStart"])
|
||||
|
||||
company_currency = _company_currency(self.company)
|
||||
base_filters = {
|
||||
"company": self.company,
|
||||
"posting_date": ["between", [self.from_date, self.to_date]],
|
||||
"is_cancelled": 0,
|
||||
}
|
||||
|
||||
# Opening balances need every account that posts in the period up
|
||||
# front; without that flag we cache account names lazily as we
|
||||
# encounter them in each batch.
|
||||
if self.include_opening_balance:
|
||||
accounts_in_period = frappe.get_all(
|
||||
"GL Entry", filters=base_filters, pluck="account", distinct=True
|
||||
)
|
||||
running_balance = _opening_balances_by_account(self.company, self.from_date, accounts_in_period)
|
||||
account_name_map = _bulk_party_field("Account", accounts_in_period, "account_name")
|
||||
else:
|
||||
running_balance = {}
|
||||
account_name_map = {}
|
||||
|
||||
source_type_map = {
|
||||
"Sales Invoice": "AR",
|
||||
"Purchase Invoice": "AP",
|
||||
"Journal Entry": "General Journal",
|
||||
"Payment Entry": "Cash Receipt",
|
||||
"Stock Entry": "Inventory",
|
||||
"Delivery Note": "Inventory Sale",
|
||||
"Purchase Receipt": "Purchases",
|
||||
}
|
||||
|
||||
total_debit = 0.0
|
||||
total_credit = 0.0
|
||||
count = 0
|
||||
start = 0
|
||||
|
||||
while True:
|
||||
batch = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters=base_filters,
|
||||
fields=[
|
||||
"name",
|
||||
"posting_date",
|
||||
"account",
|
||||
"remarks",
|
||||
"against",
|
||||
"voucher_no",
|
||||
"voucher_type",
|
||||
"debit",
|
||||
"credit",
|
||||
],
|
||||
order_by="posting_date asc, creation asc",
|
||||
limit_start=start,
|
||||
limit_page_length=GL_PAGE_SIZE,
|
||||
)
|
||||
if not batch:
|
||||
break
|
||||
|
||||
# Backfill the account-name cache for accounts new to this batch.
|
||||
new_accounts = [e.account for e in batch if e.account and e.account not in account_name_map]
|
||||
if new_accounts:
|
||||
account_name_map.update(_bulk_party_field("Account", new_accounts, "account_name"))
|
||||
|
||||
for entry in batch:
|
||||
account_name = account_name_map.get(entry.account) or entry.account
|
||||
source_type = source_type_map.get(entry.voucher_type, entry.voucher_type or "")
|
||||
debit = flt(entry.debit, 2)
|
||||
credit = flt(entry.credit, 2)
|
||||
|
||||
running_balance[entry.account] = running_balance.get(entry.account, 0.0) + debit - credit
|
||||
balance = flt(running_balance[entry.account], 2)
|
||||
|
||||
writer.writerow(
|
||||
[
|
||||
_format_date(entry.posting_date),
|
||||
entry.account,
|
||||
_clean(account_name),
|
||||
_clean(entry.remarks or ""),
|
||||
_clean(entry.against or ""),
|
||||
entry.voucher_no,
|
||||
entry.voucher_no,
|
||||
source_type,
|
||||
_money(debit),
|
||||
_money(credit),
|
||||
_money(balance),
|
||||
]
|
||||
)
|
||||
total_debit += debit
|
||||
total_credit += credit
|
||||
count += 1
|
||||
|
||||
if len(batch) < GL_PAGE_SIZE:
|
||||
break
|
||||
start += GL_PAGE_SIZE
|
||||
|
||||
writer.writerow(
|
||||
[
|
||||
"GLDataEnd",
|
||||
_money(total_debit),
|
||||
_money(total_credit),
|
||||
count,
|
||||
company_currency,
|
||||
]
|
||||
)
|
||||
return count
|
||||
|
||||
|
||||
def _clean(value):
|
||||
"""Sanitize a string for FAF CSV.
|
||||
|
||||
The spec mandates that the delimiter (``,``) must not appear inside any
|
||||
field. We follow Microsoft Dynamics 365's UAE FAF convention and
|
||||
substitute ``;`` so the original separator stays visible in the data.
|
||||
Embedded newlines are stripped because they would otherwise break the
|
||||
CSV row structure.
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).replace(",", ";").replace("\n", " ").replace("\r", " ").strip()
|
||||
|
||||
|
||||
def _format_date(d):
|
||||
"""Format a date as DD-MM-YYYY per FTA spec; missing values become 31-12-9999."""
|
||||
if not d:
|
||||
return DEFAULT_DATE
|
||||
return getdate(d).strftime("%d-%m-%Y")
|
||||
|
||||
|
||||
def _money(value):
|
||||
"""Format a numeric field as ``Decimal[14,2]`` per FTA spec.
|
||||
|
||||
Python's ``csv.writer`` calls ``str()`` on numeric values, which
|
||||
strips trailing zeros (``0.00`` → ``"0.0"``). The spec mandates two
|
||||
decimal places everywhere a Decimal[14,2] field is emitted, so we
|
||||
pre-format to a string here.
|
||||
"""
|
||||
return f"{flt(value):.2f}"
|
||||
|
||||
|
||||
def _company_currency(company):
|
||||
return frappe.db.get_value("Company", company, "default_currency") or "AED"
|
||||
|
||||
|
||||
def _fcy_for_invoice(invoice_currency, conversion_rate, company_currency):
|
||||
"""Resolve foreign-currency code + conversion factor for an invoice.
|
||||
|
||||
Returns ``("XXX", 1.0)`` when the invoice is in the company's home
|
||||
currency (no FCY columns to populate); otherwise the ISO 4217 code
|
||||
plus the conversion factor (rate to company currency).
|
||||
"""
|
||||
if not invoice_currency or invoice_currency == company_currency:
|
||||
return ("XXX", 1.0)
|
||||
return (invoice_currency, flt(conversion_rate) or 1.0)
|
||||
|
||||
|
||||
def _bulk_party_field(doctype, names, field):
|
||||
"""Return ``{name: field_value}`` for the given names. Empty input → empty dict."""
|
||||
if not names:
|
||||
return {}
|
||||
rows = frappe.get_all(
|
||||
doctype,
|
||||
filters={"name": ["in", names]},
|
||||
fields=["name", field],
|
||||
)
|
||||
return {r["name"]: (r.get(field) or "") for r in rows}
|
||||
|
||||
|
||||
def _bulk_party_country(party_doctype, party_names):
|
||||
"""Return ``{party_name: country}`` for each party.
|
||||
|
||||
Picks deterministically when a party has multiple addresses: prefer the
|
||||
one flagged ``is_primary_address``, then ``is_shipping_address``, then
|
||||
the lowest address name. Without this ordering, MariaDB would return
|
||||
rows in storage-engine order and the FAF would be non-reproducible
|
||||
across runs.
|
||||
"""
|
||||
if not party_names:
|
||||
return {}
|
||||
|
||||
dl = frappe.qb.DocType("Dynamic Link")
|
||||
addr = frappe.qb.DocType("Address")
|
||||
rows = (
|
||||
frappe.qb.from_(dl)
|
||||
.inner_join(addr)
|
||||
.on(addr.name == dl.parent)
|
||||
.where(dl.link_doctype == party_doctype)
|
||||
.where(dl.parenttype == "Address")
|
||||
.where(dl.link_name.isin(party_names))
|
||||
.where(addr.country.isnotnull())
|
||||
.where(addr.country != "")
|
||||
.select(dl.link_name, addr.country)
|
||||
.orderby(addr.is_primary_address, order=frappe.qb.desc)
|
||||
.orderby(addr.is_shipping_address, order=frappe.qb.desc)
|
||||
.orderby(addr.name)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
out = {}
|
||||
for r in rows:
|
||||
out.setdefault(r["link_name"], r["country"])
|
||||
return out
|
||||
|
||||
|
||||
def _opening_balances_by_account(company, period_start, accounts):
|
||||
"""Net pre-period balance per account: sum(debit) - sum(credit) before ``period_start``.
|
||||
|
||||
Returns ``{account: net}`` where net is debit-positive (positive for
|
||||
asset/expense accounts that carry a debit balance, negative for
|
||||
liability/equity/revenue accounts that carry a credit balance).
|
||||
Single aggregated SQL via ``frappe.qb`` — one query regardless of the
|
||||
number of accounts. Cancelled GL entries are excluded.
|
||||
"""
|
||||
if not accounts:
|
||||
return {}
|
||||
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
rows = (
|
||||
frappe.qb.from_(gle)
|
||||
.where(gle.company == company)
|
||||
.where(gle.posting_date < period_start)
|
||||
.where(gle.is_cancelled == 0)
|
||||
.where(gle.account.isin(accounts))
|
||||
.groupby(gle.account)
|
||||
.select(
|
||||
gle.account,
|
||||
Sum(gle.debit).as_("debit"),
|
||||
Sum(gle.credit).as_("credit"),
|
||||
)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
return {r["account"]: flt(r.get("debit") or 0) - flt(r.get("credit") or 0) for r in rows}
|
||||
|
||||
|
||||
def _bulk_invoice_items(child_doctype, invoice_names, fields):
|
||||
"""Return ``{parent_invoice: [items...]}`` for the given invoice names."""
|
||||
if not invoice_names:
|
||||
return {}
|
||||
items = frappe.get_all(
|
||||
child_doctype,
|
||||
filters={"parent": ["in", invoice_names]},
|
||||
fields=fields,
|
||||
order_by="parent asc, idx asc",
|
||||
)
|
||||
out = {}
|
||||
for item in items:
|
||||
out.setdefault(item["parent"], []).append(item)
|
||||
return out
|
||||
|
||||
|
||||
_FTA_TAX_CODES = ("SR", "ZR", "EX", "RC", "IG", "OA", "IA")
|
||||
|
||||
|
||||
def _bulk_tax_code_bands(item_tax_templates):
|
||||
"""Return ``{template: [(valid_from, tax_category), ...]}`` sorted desc by valid_from.
|
||||
|
||||
One ``Item Tax Template`` can have multiple ``Item Tax`` rows with
|
||||
different ``valid_from`` dates (e.g. tax code changing on a regulator
|
||||
cutover). Fetching them all up-front lets ``_resolve_tax_code`` pick
|
||||
the row that was in force on each invoice's posting date without an
|
||||
extra DB hit per line item.
|
||||
"""
|
||||
if not item_tax_templates:
|
||||
return {}
|
||||
rows = frappe.get_all(
|
||||
"Item Tax",
|
||||
filters={"item_tax_template": ["in", list(item_tax_templates)]},
|
||||
fields=["item_tax_template", "tax_category", "valid_from"],
|
||||
order_by="valid_from desc",
|
||||
)
|
||||
out = {}
|
||||
for r in rows:
|
||||
out.setdefault(r["item_tax_template"], []).append((r.get("valid_from"), r.get("tax_category")))
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_tax_code(item_tax_template, posting_date, bands_map):
|
||||
"""Derive FTA tax code (SR/ZR/EX/RC/IG/OA/IA) from one Item Tax Template.
|
||||
|
||||
Picks the Item Tax row whose ``valid_from`` is the most recent value
|
||||
that is still on or before ``posting_date``; rows with no
|
||||
``valid_from`` are treated as always-valid and used only as a
|
||||
fallback. Defaults to ``SR`` (Standard Rated) when nothing matches or
|
||||
the chosen ``tax_category`` isn't one of the FTA codes.
|
||||
"""
|
||||
if not item_tax_template:
|
||||
return "SR"
|
||||
|
||||
bands = bands_map.get(item_tax_template) or []
|
||||
posting = getdate(posting_date) if posting_date else None
|
||||
fallback_category = None
|
||||
for valid_from, tax_category in bands:
|
||||
if valid_from is None:
|
||||
fallback_category = fallback_category or tax_category
|
||||
continue
|
||||
if posting is None or getdate(valid_from) <= posting:
|
||||
return tax_category if tax_category in _FTA_TAX_CODES else "SR"
|
||||
|
||||
if fallback_category and fallback_category in _FTA_TAX_CODES:
|
||||
return fallback_category
|
||||
return "SR"
|
||||
@@ -1,238 +0,0 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
|
||||
class TestFTAAuditFile(FrappeTestCase):
|
||||
def setUp(self):
|
||||
"""Create a UAE test company with TRN before each test.
|
||||
|
||||
Per-test creation (not setUpClass) because FrappeTestCase rolls
|
||||
back the database after each test, including class-level fixtures.
|
||||
"""
|
||||
self.company = self._get_or_create_test_company()
|
||||
|
||||
def _get_or_create_test_company(self):
|
||||
company_name = "_Test Company UAE"
|
||||
if not frappe.db.exists("Company", company_name):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Company",
|
||||
"company_name": company_name,
|
||||
"abbr": "_TCU",
|
||||
"country": "United Arab Emirates",
|
||||
"default_currency": "AED",
|
||||
"tax_id": "100123456789012",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
else:
|
||||
company = frappe.get_doc("Company", company_name)
|
||||
if not company.tax_id:
|
||||
company.tax_id = "100123456789012"
|
||||
company.save(ignore_permissions=True)
|
||||
return company_name
|
||||
|
||||
def test_fta_audit_file_creation(self):
|
||||
"""Test that FTA Audit File can be created."""
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "FTA Audit File",
|
||||
"company": self.company,
|
||||
"from_date": "2024-01-01",
|
||||
"to_date": "2024-03-31",
|
||||
"file_type": "VAT",
|
||||
}
|
||||
)
|
||||
doc.insert()
|
||||
|
||||
self.assertTrue(doc.name)
|
||||
self.assertEqual(doc.status, "Draft")
|
||||
self.assertEqual(doc.file_type, "VAT")
|
||||
|
||||
def test_date_validation(self):
|
||||
"""Test that from_date cannot be after to_date."""
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "FTA Audit File",
|
||||
"company": self.company,
|
||||
"from_date": "2024-03-31",
|
||||
"to_date": "2024-01-01",
|
||||
"file_type": "VAT",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
def test_company_trn_validation(self):
|
||||
"""Test that company must have a TRN."""
|
||||
company_no_trn = "_Test Company No TRN"
|
||||
|
||||
if not frappe.db.exists("Company", company_no_trn):
|
||||
company = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Company",
|
||||
"company_name": company_no_trn,
|
||||
"abbr": "_TCNT",
|
||||
"country": "United Arab Emirates",
|
||||
"default_currency": "AED",
|
||||
}
|
||||
)
|
||||
company.insert(ignore_permissions=True)
|
||||
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "FTA Audit File",
|
||||
"company": company_no_trn,
|
||||
"from_date": "2024-01-01",
|
||||
"to_date": "2024-03-31",
|
||||
"file_type": "VAT",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
def test_generate_faf_empty_period(self):
|
||||
"""End-to-end smoke test: generate against an empty period.
|
||||
|
||||
With ``frappe.flags.in_test`` set by FrappeTestCase, the enqueued
|
||||
job runs synchronously, so by the time generate_faf() returns the
|
||||
doc has reached its terminal status.
|
||||
"""
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "FTA Audit File",
|
||||
"company": self.company,
|
||||
"from_date": "2099-01-01",
|
||||
"to_date": "2099-01-31",
|
||||
"file_type": "VAT",
|
||||
}
|
||||
)
|
||||
doc.insert()
|
||||
|
||||
result = doc.generate_faf()
|
||||
self.assertTrue(result["success"])
|
||||
|
||||
doc.reload()
|
||||
self.assertEqual(doc.status, "Generated")
|
||||
self.assertTrue(doc.faf_file)
|
||||
|
||||
self.assertIn("Company Information written", doc.generation_log)
|
||||
self.assertIn("Purchase Listing written", doc.generation_log)
|
||||
self.assertIn("Supply Listing written", doc.generation_log)
|
||||
self.assertIn("General Ledger written", doc.generation_log)
|
||||
|
||||
def test_generate_faf_csv_structure(self):
|
||||
"""The generated CSV must contain the four spec section markers."""
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "FTA Audit File",
|
||||
"company": self.company,
|
||||
"from_date": "2099-02-01",
|
||||
"to_date": "2099-02-28",
|
||||
"file_type": "VAT",
|
||||
}
|
||||
)
|
||||
doc.insert()
|
||||
doc.generate_faf()
|
||||
doc.reload()
|
||||
self.assertEqual(doc.status, "Generated")
|
||||
|
||||
file_doc = frappe.get_doc("File", {"file_url": doc.faf_file})
|
||||
csv_content = file_doc.get_content()
|
||||
if isinstance(csv_content, bytes):
|
||||
csv_content = csv_content.decode("utf-8")
|
||||
for marker in (
|
||||
"CompInfoStart",
|
||||
"CompInfoEnd",
|
||||
"PurcDataStart",
|
||||
"PurcDataEnd",
|
||||
"SuppDataStart",
|
||||
"SuppDataEnd",
|
||||
"GLDataStart",
|
||||
"GLDataEnd",
|
||||
):
|
||||
self.assertIn(marker, csv_content, f"Missing FAF section marker {marker!r}")
|
||||
|
||||
self.assertIn("FAFv1.0.0", csv_content)
|
||||
|
||||
def test_tax_agent_fields_appear_in_company_info(self):
|
||||
"""Tax Agency / Tax Agent details must round-trip into the CSV."""
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "FTA Audit File",
|
||||
"company": self.company,
|
||||
"from_date": "2099-04-01",
|
||||
"to_date": "2099-04-30",
|
||||
"file_type": "VAT",
|
||||
"tax_agency_name": "Acme Tax Agency",
|
||||
"tan": "TAN-555-001",
|
||||
"tax_agent_name": "Jane Auditor",
|
||||
"taan": "TAAN-777",
|
||||
}
|
||||
)
|
||||
doc.insert()
|
||||
doc.generate_faf()
|
||||
doc.reload()
|
||||
self.assertEqual(doc.status, "Generated")
|
||||
|
||||
file_doc = frappe.get_doc("File", {"file_url": doc.faf_file})
|
||||
csv_content = file_doc.get_content()
|
||||
if isinstance(csv_content, bytes):
|
||||
csv_content = csv_content.decode("utf-8")
|
||||
|
||||
for value in ("Acme Tax Agency", "TAN-555-001", "Jane Auditor", "TAAN-777"):
|
||||
self.assertIn(value, csv_content, f"Missing tax-agent value {value!r} in FAF")
|
||||
|
||||
def test_decimal_fields_use_two_decimal_places(self):
|
||||
"""Decimal[14,2] cells must always emit two decimal places per spec."""
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "FTA Audit File",
|
||||
"company": self.company,
|
||||
"from_date": "2099-05-01",
|
||||
"to_date": "2099-05-31",
|
||||
"file_type": "VAT",
|
||||
}
|
||||
)
|
||||
doc.insert()
|
||||
doc.generate_faf()
|
||||
doc.reload()
|
||||
|
||||
file_doc = frappe.get_doc("File", {"file_url": doc.faf_file})
|
||||
csv_content = file_doc.get_content()
|
||||
if isinstance(csv_content, bytes):
|
||||
csv_content = csv_content.decode("utf-8")
|
||||
|
||||
self.assertIn("PurcDataEnd,0.00,0.00,0", csv_content)
|
||||
self.assertIn("SuppDataEnd,0.00,0.00,0", csv_content)
|
||||
self.assertIn("GLDataEnd,0.00,0.00,0,AED", csv_content)
|
||||
|
||||
self.assertNotIn("PurcDataEnd,0.0,", csv_content)
|
||||
self.assertNotIn("SuppDataEnd,0.0,", csv_content)
|
||||
self.assertNotIn("GLDataEnd,0.0,", csv_content)
|
||||
|
||||
def test_mark_as_submitted_workflow(self):
|
||||
"""Generated docs can be marked submitted; non-Generated cannot."""
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "FTA Audit File",
|
||||
"company": self.company,
|
||||
"from_date": "2099-05-01",
|
||||
"to_date": "2099-05-31",
|
||||
"file_type": "VAT",
|
||||
}
|
||||
)
|
||||
doc.insert()
|
||||
|
||||
self.assertRaises(frappe.ValidationError, doc.mark_as_submitted)
|
||||
|
||||
doc.generate_faf()
|
||||
doc.reload()
|
||||
self.assertEqual(doc.status, "Generated")
|
||||
|
||||
result = doc.mark_as_submitted()
|
||||
self.assertTrue(result["success"])
|
||||
doc.reload()
|
||||
self.assertEqual(doc.status, "Submitted")
|
||||
@@ -1,104 +1,77 @@
|
||||
{%
|
||||
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: 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; }
|
||||
.print-format {
|
||||
padding: 10mm;
|
||||
font-size: 8.0pt !important;
|
||||
font-family: Tahoma, sans-serif;
|
||||
}
|
||||
</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") %}</h3>
|
||||
<h3 style="margin-top:0; font-weight:500">{%= __("VAT on Sales and All Other Outputs") %}</h2>
|
||||
|
||||
<table class="table table-bordered">
|
||||
|
||||
<thead>
|
||||
<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>
|
||||
<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<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>
|
||||
{% 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) %}
|
||||
{% } %}
|
||||
</tr>
|
||||
</td>
|
||||
{% } %}
|
||||
</tr>
|
||||
{% } %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3 style="margin-top:0; font-weight:500">{%= __("VAT on Expenses and All Other Inputs") %}</h3>
|
||||
<h3 style="margin-top:0; font-weight:500">{%= __("VAT on Expenses and All Other Inputs") %}</h2>
|
||||
|
||||
<table class="table table-bordered">
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<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>
|
||||
<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=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>
|
||||
{% 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) %}
|
||||
{% } %}
|
||||
</tr>
|
||||
</td>
|
||||
{% } %}
|
||||
{% } %}
|
||||
</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,14 +33,11 @@ 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 == "Net VAT Due" ||
|
||||
data.legend == "Total") &&
|
||||
data.legend == "VAT on Expenses and All Other Inputs") &&
|
||||
data.legend == value
|
||||
) {
|
||||
value = $(`<span>${value}</span>`);
|
||||
|
||||
@@ -2,75 +2,17 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
from html import escape
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Coalesce, Sum
|
||||
from frappe.utils import flt
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
from erpnext import get_region
|
||||
|
||||
# Per-request memoization cache for the helper functions below. Stored on
|
||||
# ``frappe.local`` so concurrent requests under gevent/threaded workers
|
||||
# never share or race on this state; cleared at the start of every
|
||||
# ``execute()`` so each report run gets fresh data.
|
||||
_CACHE_ATTR = "_uae_vat_201_cache"
|
||||
|
||||
|
||||
def _get_cache():
|
||||
cache = getattr(frappe.local, _CACHE_ATTR, None)
|
||||
if cache is None:
|
||||
cache = {}
|
||||
setattr(frappe.local, _CACHE_ATTR, cache)
|
||||
return cache
|
||||
|
||||
|
||||
def _drill_down_link(text, filters, **extra):
|
||||
"""Return an `<a>` tag pointing at the UAE VAT Register report.
|
||||
|
||||
Filter values are URL-encoded so company names with ``&`` or other
|
||||
reserved characters don't break the query string, and the link text
|
||||
is HTML-escaped to prevent injection from user-controlled fields.
|
||||
"""
|
||||
params = {}
|
||||
for key in ("company", "from_date", "to_date"):
|
||||
value = (filters or {}).get(key)
|
||||
if value:
|
||||
params[key] = value
|
||||
for key, value in extra.items():
|
||||
if value is not None:
|
||||
params[key] = value
|
||||
query = urlencode(params)
|
||||
return f'<a href="/app/query-report/UAE VAT Register?{query}">{escape(str(text))}</a>'
|
||||
|
||||
|
||||
def _cached(fn):
|
||||
def wrapper(filters, *args, **kwargs):
|
||||
# ``frappe.local`` survives across unit-test methods (it is request
|
||||
# scoped, not test scoped). Two tests that call the same helper with
|
||||
# equivalent filter dicts would otherwise share a cached value from
|
||||
# the first test's data set. Bypass the cache in tests so each
|
||||
# call hits the DB; production callers (one execute() per HTTP
|
||||
# request, cache cleared at its start) still see the optimisation.
|
||||
if frappe.flags.in_test:
|
||||
return fn(filters, *args, **kwargs)
|
||||
cache = _get_cache()
|
||||
key = (fn.__name__, tuple(sorted((filters or {}).items())))
|
||||
if key not in cache:
|
||||
cache[key] = fn(filters, *args, **kwargs)
|
||||
return cache[key]
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
filters = filters or {}
|
||||
validate_company_region(filters)
|
||||
_get_cache().clear()
|
||||
columns = get_columns()
|
||||
data = get_data(filters)
|
||||
data, emirates, amounts_by_emirate = get_data(filters)
|
||||
return columns, data
|
||||
|
||||
|
||||
@@ -106,86 +48,23 @@ 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 = []
|
||||
amounts_by_emirate = append_vat_on_sales(data, filters)
|
||||
emirates, amounts_by_emirate = append_vat_on_sales(data, filters)
|
||||
append_vat_on_expenses(data, filters)
|
||||
net_vat_due(data, filters, amounts_by_emirate)
|
||||
|
||||
dubai_label_override = _company_emirate_label(filters)
|
||||
|
||||
final_data = []
|
||||
for row in data:
|
||||
key = row.get("_key")
|
||||
legend = row.get("legend")
|
||||
new_legend = legend
|
||||
|
||||
if key and key.startswith("emirate:"):
|
||||
emirate = key.split(":", 1)[1]
|
||||
label = dubai_label_override if emirate == "Dubai" and dubai_label_override else legend
|
||||
new_legend = _drill_down_link(
|
||||
label, filters, doc_type="Sales Invoice", vat=emirate, category="Standard"
|
||||
)
|
||||
elif key == "reverse_charge_supplies":
|
||||
new_legend = _drill_down_link(legend, filters, doc_type="Purchase Invoice", reverse_charge="Y")
|
||||
elif key == "zero_rated":
|
||||
new_legend = _drill_down_link(legend, filters, doc_type="Sales Invoice", category="Zero Rated")
|
||||
elif key == "exempt_supplies":
|
||||
new_legend = _drill_down_link(legend, filters, doc_type="Sales Invoice", category="Exempt Rated")
|
||||
elif key == "standard_rated_expenses":
|
||||
new_legend = _drill_down_link(legend, filters, doc_type="Purchase Invoice")
|
||||
|
||||
final_data.append(
|
||||
{
|
||||
"no": row.get("no"),
|
||||
"legend": new_legend,
|
||||
"amount": row.get("amount"),
|
||||
"vat_amount": row.get("vat_amount"),
|
||||
}
|
||||
)
|
||||
|
||||
return final_data
|
||||
|
||||
|
||||
def _company_emirate_label(filters):
|
||||
"""Return the home-emirate label for the company in ``filters`` if any.
|
||||
|
||||
The Dubai row is conventionally relabeled with the actual emirate of
|
||||
the filtered company's primary address. Falls back to ``None`` when
|
||||
no company filter is set or the address has no emirate, in which case
|
||||
callers keep the original "Standard rated supplies in Dubai" wording.
|
||||
"""
|
||||
company = (filters or {}).get("company")
|
||||
if not company:
|
||||
return None
|
||||
address = frappe.get_all(
|
||||
"Address",
|
||||
filters=[
|
||||
["Dynamic Link", "link_doctype", "=", "Company"],
|
||||
["Dynamic Link", "link_name", "=", company],
|
||||
["Address", "is_your_company_address", "=", 1],
|
||||
],
|
||||
fields=["emirate"],
|
||||
limit=1,
|
||||
)
|
||||
if address and address[0].get("emirate"):
|
||||
return _("Standard rated supplies in {0}").format(address[0]["emirate"])
|
||||
return None
|
||||
return data, emirates, amounts_by_emirate
|
||||
|
||||
|
||||
def append_vat_on_sales(data, filters):
|
||||
"""Appends Sales and All Other Outputs."""
|
||||
append_data(data, "", _("VAT on Sales and All Other Outputs"), "", "")
|
||||
|
||||
amounts_by_emirate = standard_rated_expenses_emiratewise(data, filters)
|
||||
|
||||
si_amount = amounts_by_emirate[1]
|
||||
si_vat = amounts_by_emirate[2]
|
||||
emirates, amounts_by_emirate = standard_rated_expenses_emiratewise(data, filters)
|
||||
|
||||
append_data(
|
||||
data,
|
||||
"2",
|
||||
_("Tax Refunds provided to Tourists under the Tax Refunds for Tourists Scheme"),
|
||||
format_currency_signed((-1) * get_tourist_tax_return_total(filters)),
|
||||
format_currency_signed((-1) * get_tourist_tax_return_tax(filters)),
|
||||
frappe.format((-1) * get_tourist_tax_return_total(filters), "Currency"),
|
||||
frappe.format((-1) * get_tourist_tax_return_tax(filters), "Currency"),
|
||||
)
|
||||
|
||||
append_data(
|
||||
@@ -194,48 +73,15 @@ def append_vat_on_sales(data, filters):
|
||||
_("Supplies subject to the reverse charge provision"),
|
||||
frappe.format(get_reverse_charge_total(filters), "Currency"),
|
||||
frappe.format(get_reverse_charge_tax(filters), "Currency"),
|
||||
key="reverse_charge_supplies",
|
||||
)
|
||||
|
||||
append_data(
|
||||
data,
|
||||
"4",
|
||||
_("Zero Rated"),
|
||||
frappe.format(get_zero_rated_total(filters), "Currency"),
|
||||
"-",
|
||||
key="zero_rated",
|
||||
)
|
||||
append_data(data, "4", _("Zero Rated"), frappe.format(get_zero_rated_total(filters), "Currency"), "-")
|
||||
|
||||
append_data(
|
||||
data,
|
||||
"5",
|
||||
_("Exempt Supplies"),
|
||||
frappe.format(get_exempt_total(filters), "Currency"),
|
||||
"-",
|
||||
key="exempt_supplies",
|
||||
)
|
||||
|
||||
append_data(
|
||||
data,
|
||||
"8",
|
||||
_("Total"),
|
||||
frappe.format(
|
||||
(-1) * get_tourist_tax_return_total(filters)
|
||||
+ get_reverse_charge_total(filters)
|
||||
+ get_zero_rated_total(filters)
|
||||
+ get_exempt_total(filters)
|
||||
+ sum(si_amount),
|
||||
"Currency",
|
||||
),
|
||||
frappe.format(
|
||||
(-1) * get_tourist_tax_return_tax(filters) + get_reverse_charge_tax(filters) + sum(si_vat),
|
||||
"Currency",
|
||||
),
|
||||
)
|
||||
append_data(data, "5", _("Exempt Supplies"), frappe.format(get_exempt_total(filters), "Currency"), "-")
|
||||
|
||||
append_data(data, "", "", "", "")
|
||||
|
||||
return amounts_by_emirate
|
||||
return emirates, amounts_by_emirate
|
||||
|
||||
|
||||
def standard_rated_expenses_emiratewise(data, filters):
|
||||
@@ -252,22 +98,16 @@ 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 amounts_by_emirate
|
||||
return emirates, amounts_by_emirate
|
||||
|
||||
|
||||
def append_emiratewise_expenses(data, emirates, amounts_by_emirate):
|
||||
"""Append emiratewise standard rated expenses and vat."""
|
||||
s_amount = []
|
||||
v_amount = []
|
||||
for no, emirate in enumerate(emirates, 97):
|
||||
if emirate in amounts_by_emirate:
|
||||
amounts_by_emirate[emirate]["no"] = _("1{0}").format(chr(no))
|
||||
amounts_by_emirate[emirate]["legend"] = _("Standard rated supplies in {0}").format(emirate)
|
||||
amounts_by_emirate[emirate]["_key"] = f"emirate:{emirate}"
|
||||
data.append(amounts_by_emirate[emirate])
|
||||
|
||||
s_amount.append(amounts_by_emirate[emirate].get("raw_amount") or 0)
|
||||
v_amount.append(amounts_by_emirate[emirate].get("raw_vat_amount") or 0)
|
||||
else:
|
||||
append_data(
|
||||
data,
|
||||
@@ -275,9 +115,8 @@ def append_emiratewise_expenses(data, emirates, amounts_by_emirate):
|
||||
_("Standard rated supplies in {0}").format(emirate),
|
||||
frappe.format(0, "Currency"),
|
||||
frappe.format(0, "Currency"),
|
||||
key=f"emirate:{emirate}",
|
||||
)
|
||||
return amounts_by_emirate, s_amount, v_amount
|
||||
return amounts_by_emirate
|
||||
|
||||
|
||||
def append_vat_on_expenses(data, filters):
|
||||
@@ -289,7 +128,6 @@ def append_vat_on_expenses(data, filters):
|
||||
_("Standard Rated Expenses"),
|
||||
frappe.format(get_standard_rated_expenses_total(filters), "Currency"),
|
||||
frappe.format(get_standard_rated_expenses_tax(filters), "Currency"),
|
||||
key="standard_rated_expenses",
|
||||
)
|
||||
append_data(
|
||||
data,
|
||||
@@ -299,103 +137,30 @@ 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 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 net_vat_due(data, filters, amounts_by_emirate):
|
||||
si_vat = amounts_by_emirate[2]
|
||||
|
||||
append_data(data, "", "", "", "")
|
||||
append_data(data, "", _("Net VAT Due"), "", "")
|
||||
append_data(
|
||||
data,
|
||||
"12",
|
||||
_("Total value of due tax for the period"),
|
||||
frappe.format(0.00, "Currency"),
|
||||
frappe.format(
|
||||
sum(si_vat) + (-1) * get_tourist_tax_return_tax(filters) + get_reverse_charge_tax(filters),
|
||||
"Currency",
|
||||
),
|
||||
)
|
||||
append_data(
|
||||
data,
|
||||
"13",
|
||||
_("Total value of recoverable tax for the period"),
|
||||
frappe.format(0.00, "Currency"),
|
||||
frappe.format(
|
||||
get_standard_rated_expenses_tax(filters) + get_reverse_charge_recoverable_tax(filters),
|
||||
"Currency",
|
||||
),
|
||||
)
|
||||
|
||||
# Calculate payable tax: Due Tax - Recoverable Tax
|
||||
due_tax = sum(si_vat) + (-1) * get_tourist_tax_return_tax(filters) + get_reverse_charge_tax(filters)
|
||||
recoverable_tax = get_standard_rated_expenses_tax(filters) + get_reverse_charge_recoverable_tax(filters)
|
||||
payable_tax = due_tax - recoverable_tax
|
||||
|
||||
append_data(
|
||||
data,
|
||||
"14",
|
||||
_("Payable tax for the period"),
|
||||
frappe.format(0.00, "Currency"),
|
||||
frappe.format(payable_tax, "Currency"),
|
||||
)
|
||||
|
||||
|
||||
def append_data(data, no, legend, amount, vat_amount, key=None):
|
||||
"""Append one row to ``data``.
|
||||
|
||||
``key`` (when provided) is a language-independent identifier used by
|
||||
``get_data`` to decide which rows get drill-down links. Without it,
|
||||
dispatch would have to match the localized ``legend`` text and would
|
||||
silently break under any non-English language.
|
||||
"""
|
||||
data.append({"no": no, "legend": legend, "amount": amount, "vat_amount": vat_amount, "_key": key})
|
||||
|
||||
|
||||
def format_currency_signed(value):
|
||||
"""Format a number as currency, placing the minus sign *before* the currency symbol
|
||||
when negative (e.g. "-د.إ 5,000.00" rather than "د.إ -5,000.00")."""
|
||||
if value is None:
|
||||
value = 0
|
||||
if value < 0:
|
||||
return "-" + frappe.format(abs(value), "Currency")
|
||||
return frappe.format(value, "Currency")
|
||||
|
||||
|
||||
@_cached
|
||||
def get_total_emiratewise(filters):
|
||||
"""Returns Emiratewise Amount and Taxes."""
|
||||
si = frappe.qb.DocType("Sales Invoice")
|
||||
sii = frappe.qb.DocType("Sales Invoice Item")
|
||||
i = frappe.qb.DocType("Sales Invoice Item")
|
||||
s = frappe.qb.DocType("Sales Invoice")
|
||||
query = (
|
||||
frappe.qb.from_(sii)
|
||||
.inner_join(si)
|
||||
.on(sii.parent == si.name)
|
||||
.where(si.docstatus == 1)
|
||||
.where(sii.is_exempt != 1)
|
||||
.where(sii.is_zero_rated != 1)
|
||||
.groupby(si.vat_emirate)
|
||||
.select(
|
||||
si.vat_emirate.as_("emirate"),
|
||||
Coalesce(Sum(sii.base_net_amount), 0).as_("total"),
|
||||
Coalesce(Sum(sii.tax_amount), 0),
|
||||
)
|
||||
frappe.qb.from_(i)
|
||||
.inner_join(s)
|
||||
.on(i.parent == s.name)
|
||||
.select(s.vat_emirate.as_("emirate"), Sum(i.base_net_amount).as_("total"), Sum(i.tax_amount))
|
||||
.where((s.docstatus == 1) & (i.is_exempt != 1) & (i.is_zero_rated != 1))
|
||||
.groupby(s.vat_emirate)
|
||||
)
|
||||
query = _apply_period_filters(query, si, filters)
|
||||
return query.run()
|
||||
for condition in get_conditions(filters, s):
|
||||
query = query.where(condition)
|
||||
try:
|
||||
return query.run()
|
||||
except (IndexError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
def get_emirates():
|
||||
@@ -403,185 +168,245 @@ def get_emirates():
|
||||
return ["Abu Dhabi", "Dubai", "Sharjah", "Ajman", "Umm Al Quwain", "Ras Al Khaimah", "Fujairah"]
|
||||
|
||||
|
||||
def _apply_period_filters(query, table, filters):
|
||||
"""Apply company / posting-date filters from ``filters`` to a frappe.qb query."""
|
||||
filters = filters or {}
|
||||
def get_filters(filters):
|
||||
"""The conditions to be used to filter data to calculate the total sale."""
|
||||
query_filters = []
|
||||
if filters.get("company"):
|
||||
query = query.where(table.company == filters["company"])
|
||||
query_filters.append(["company", "=", filters["company"]])
|
||||
if filters.get("from_date"):
|
||||
query = query.where(table.posting_date >= filters["from_date"])
|
||||
if filters.get("to_date"):
|
||||
query = query.where(table.posting_date <= filters["to_date"])
|
||||
return query
|
||||
query_filters.append(["posting_date", ">=", filters["from_date"]])
|
||||
if filters.get("from_date"):
|
||||
query_filters.append(["posting_date", "<=", filters["to_date"]])
|
||||
return query_filters
|
||||
|
||||
|
||||
def _sum_invoice_field(doctype, field, filters, extra_where=None):
|
||||
"""Return ``sum(field)`` on a submitted invoice doctype with the standard
|
||||
period filters. ``extra_where(table)`` may yield additional ``Criterion``s."""
|
||||
table = frappe.qb.DocType(doctype)
|
||||
query = frappe.qb.from_(table).where(table.docstatus == 1).select(Coalesce(Sum(table[field]), 0))
|
||||
query = _apply_period_filters(query, table, filters)
|
||||
if extra_where is not None:
|
||||
for criterion in extra_where(table):
|
||||
query = query.where(criterion)
|
||||
result = query.run()
|
||||
return flt(result[0][0]) if result else 0
|
||||
|
||||
|
||||
def _sum_item_field(parent_doctype, child_doctype, field, filters, extra_item_where=None):
|
||||
"""Return ``sum(child.field)`` for child rows of submitted parents in the period."""
|
||||
parent = frappe.qb.DocType(parent_doctype)
|
||||
child = frappe.qb.DocType(child_doctype)
|
||||
query = (
|
||||
frappe.qb.from_(child)
|
||||
.inner_join(parent)
|
||||
.on(child.parent == parent.name)
|
||||
.where(parent.docstatus == 1)
|
||||
.select(Coalesce(Sum(child[field]), 0))
|
||||
)
|
||||
query = _apply_period_filters(query, parent, filters)
|
||||
if extra_item_where is not None:
|
||||
for criterion in extra_item_where(child):
|
||||
query = query.where(criterion)
|
||||
result = query.run()
|
||||
return flt(result[0][0]) if result else 0
|
||||
|
||||
|
||||
def _sum_vat_account_debit(filters, recoverable=False):
|
||||
"""Sum of GL debit for reverse-charge purchases booked to UAE VAT Accounts.
|
||||
|
||||
With ``recoverable=True``, multiplies the debit by the invoice's
|
||||
``recoverable_reverse_charge`` percentage (and only sums rows with a
|
||||
non-zero recoverable rate). Returns 0 when no company filter is set,
|
||||
since UAE VAT Accounts are scoped per company.
|
||||
"""
|
||||
if not (filters or {}).get("company"):
|
||||
return 0
|
||||
|
||||
pi = frappe.qb.DocType("Purchase Invoice")
|
||||
gl = frappe.qb.DocType("GL Entry")
|
||||
uva = frappe.qb.DocType("UAE VAT Account")
|
||||
|
||||
vat_accounts = frappe.qb.from_(uva).where(uva.parent == filters["company"]).select(uva.account)
|
||||
|
||||
amount = gl.debit
|
||||
if recoverable:
|
||||
amount = amount * pi.recoverable_reverse_charge / 100
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(pi)
|
||||
.inner_join(gl)
|
||||
.on(gl.voucher_no == pi.name)
|
||||
.where(pi.reverse_charge == "Y")
|
||||
.where(pi.docstatus == 1)
|
||||
.where(gl.docstatus == 1)
|
||||
.where(gl.account.isin(vat_accounts))
|
||||
.select(Coalesce(Sum(amount), 0))
|
||||
)
|
||||
if recoverable:
|
||||
query = query.where(pi.recoverable_reverse_charge > 0)
|
||||
query = _apply_period_filters(query, pi, filters)
|
||||
result = query.run()
|
||||
return flt(result[0][0]) if result else 0
|
||||
|
||||
|
||||
@_cached
|
||||
def get_reverse_charge_total(filters):
|
||||
"""Returns the sum of the total of each Purchase invoice made."""
|
||||
return _sum_invoice_field(
|
||||
"Purchase Invoice",
|
||||
"base_net_total",
|
||||
filters,
|
||||
extra_where=lambda t: [t.reverse_charge == "Y"],
|
||||
)
|
||||
query_filters = get_filters(filters)
|
||||
query_filters.append(["reverse_charge", "=", "Y"])
|
||||
query_filters.append(["docstatus", "=", 1])
|
||||
try:
|
||||
return (
|
||||
frappe.db.get_all(
|
||||
"Purchase Invoice",
|
||||
filters=query_filters,
|
||||
fields=[{"SUM": "base_total"}],
|
||||
as_list=True,
|
||||
limit=1,
|
||||
)[0][0]
|
||||
or 0
|
||||
)
|
||||
except (IndexError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
@_cached
|
||||
def get_reverse_charge_tax(filters):
|
||||
"""Returns the sum of the tax of each Purchase invoice made."""
|
||||
return _sum_vat_account_debit(filters)
|
||||
p = frappe.qb.DocType("Purchase Invoice")
|
||||
gl = frappe.qb.DocType("GL Entry")
|
||||
uae_vat = frappe.qb.DocType("UAE VAT Account")
|
||||
query = (
|
||||
frappe.qb.from_(p)
|
||||
.inner_join(gl)
|
||||
.on(gl.voucher_no == p.name)
|
||||
.select(Sum(gl.debit))
|
||||
.where(
|
||||
(p.reverse_charge == "Y")
|
||||
& (p.docstatus == 1)
|
||||
& (gl.docstatus == 1)
|
||||
& gl.account.isin(
|
||||
frappe.qb.from_(uae_vat)
|
||||
.select(uae_vat.account)
|
||||
.where(uae_vat.parent == filters.get("company"))
|
||||
)
|
||||
)
|
||||
)
|
||||
for condition in get_conditions_join(filters, p):
|
||||
query = query.where(condition)
|
||||
return query.run()[0][0] or 0
|
||||
|
||||
|
||||
@_cached
|
||||
def get_reverse_charge_recoverable_total(filters):
|
||||
"""Returns the sum of the total of each Purchase invoice made with recoverable reverse charge."""
|
||||
return _sum_invoice_field(
|
||||
"Purchase Invoice",
|
||||
"base_net_total",
|
||||
filters,
|
||||
extra_where=lambda t: [t.reverse_charge == "Y", t.recoverable_reverse_charge > 0],
|
||||
)
|
||||
query_filters = get_filters(filters)
|
||||
query_filters.append(["reverse_charge", "=", "Y"])
|
||||
query_filters.append(["recoverable_reverse_charge", ">", "0"])
|
||||
query_filters.append(["docstatus", "=", 1])
|
||||
try:
|
||||
return (
|
||||
frappe.db.get_all(
|
||||
"Purchase Invoice",
|
||||
filters=query_filters,
|
||||
fields=[{"SUM": "base_total"}],
|
||||
as_list=True,
|
||||
limit=1,
|
||||
)[0][0]
|
||||
or 0
|
||||
)
|
||||
except (IndexError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
@_cached
|
||||
def get_reverse_charge_recoverable_tax(filters):
|
||||
"""Returns the sum of the tax of each Purchase invoice made."""
|
||||
return _sum_vat_account_debit(filters, recoverable=True)
|
||||
p = frappe.qb.DocType("Purchase Invoice")
|
||||
gl = frappe.qb.DocType("GL Entry")
|
||||
uae_vat = frappe.qb.DocType("UAE VAT Account")
|
||||
query = (
|
||||
frappe.qb.from_(p)
|
||||
.inner_join(gl)
|
||||
.on(gl.voucher_no == p.name)
|
||||
.select(Sum(gl.debit * p.recoverable_reverse_charge / 100))
|
||||
.where(
|
||||
(p.reverse_charge == "Y")
|
||||
& (p.docstatus == 1)
|
||||
& (p.recoverable_reverse_charge > 0)
|
||||
& (gl.docstatus == 1)
|
||||
& gl.account.isin(
|
||||
frappe.qb.from_(uae_vat)
|
||||
.select(uae_vat.account)
|
||||
.where(uae_vat.parent == filters.get("company"))
|
||||
)
|
||||
)
|
||||
)
|
||||
for condition in get_conditions_join(filters, p):
|
||||
query = query.where(condition)
|
||||
return query.run()[0][0] or 0
|
||||
|
||||
|
||||
def get_conditions_join(filters, p):
|
||||
"""The conditions to be used to filter data to calculate the total vat."""
|
||||
conditions = []
|
||||
if filters.get("company"):
|
||||
conditions.append(p.company == filters.get("company"))
|
||||
if filters.get("from_date"):
|
||||
conditions.append(p.posting_date >= filters.get("from_date"))
|
||||
if filters.get("to_date"):
|
||||
conditions.append(p.posting_date <= filters.get("to_date"))
|
||||
return conditions
|
||||
|
||||
|
||||
@_cached
|
||||
def get_standard_rated_expenses_total(filters):
|
||||
"""Returns the sum of the total of each Purchase invoice made with recoverable reverse charge."""
|
||||
return _sum_invoice_field(
|
||||
"Purchase Invoice",
|
||||
"base_net_total",
|
||||
filters,
|
||||
extra_where=lambda t: [t.recoverable_standard_rated_expenses > 0],
|
||||
)
|
||||
query_filters = get_filters(filters)
|
||||
query_filters.append(["recoverable_standard_rated_expenses", ">", 0])
|
||||
query_filters.append(["docstatus", "=", 1])
|
||||
try:
|
||||
return (
|
||||
frappe.db.get_all(
|
||||
"Purchase Invoice",
|
||||
filters=query_filters,
|
||||
fields=[{"SUM": "base_total"}],
|
||||
as_list=True,
|
||||
limit=1,
|
||||
)[0][0]
|
||||
or 0
|
||||
)
|
||||
except (IndexError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
@_cached
|
||||
def get_standard_rated_expenses_tax(filters):
|
||||
"""Returns the sum of the tax of each Purchase invoice made."""
|
||||
return _sum_invoice_field(
|
||||
"Purchase Invoice",
|
||||
"recoverable_standard_rated_expenses",
|
||||
filters,
|
||||
extra_where=lambda t: [t.recoverable_standard_rated_expenses > 0],
|
||||
)
|
||||
query_filters = get_filters(filters)
|
||||
query_filters.append(["recoverable_standard_rated_expenses", ">", 0])
|
||||
query_filters.append(["docstatus", "=", 1])
|
||||
try:
|
||||
return (
|
||||
frappe.db.get_all(
|
||||
"Purchase Invoice",
|
||||
filters=query_filters,
|
||||
fields=[{"SUM": "recoverable_standard_rated_expenses"}],
|
||||
as_list=True,
|
||||
limit=1,
|
||||
)[0][0]
|
||||
or 0
|
||||
)
|
||||
except (IndexError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
@_cached
|
||||
def get_tourist_tax_return_total(filters):
|
||||
"""Returns the sum of the total of each Sales invoice with non zero tourist_tax_return."""
|
||||
return _sum_invoice_field(
|
||||
"Sales Invoice",
|
||||
"base_net_total",
|
||||
filters,
|
||||
extra_where=lambda t: [t.tourist_tax_return > 0],
|
||||
)
|
||||
query_filters = get_filters(filters)
|
||||
query_filters.append(["tourist_tax_return", ">", 0])
|
||||
query_filters.append(["docstatus", "=", 1])
|
||||
try:
|
||||
return (
|
||||
frappe.db.get_all(
|
||||
"Sales Invoice", filters=query_filters, fields=[{"SUM": "base_total"}], as_list=True, limit=1
|
||||
)[0][0]
|
||||
or 0
|
||||
)
|
||||
except (IndexError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
@_cached
|
||||
def get_tourist_tax_return_tax(filters):
|
||||
"""Returns the sum of the tax of each Sales invoice with non zero tourist_tax_return."""
|
||||
return _sum_invoice_field(
|
||||
"Sales Invoice",
|
||||
"tourist_tax_return",
|
||||
filters,
|
||||
extra_where=lambda t: [t.tourist_tax_return > 0],
|
||||
)
|
||||
query_filters = get_filters(filters)
|
||||
query_filters.append(["tourist_tax_return", ">", 0])
|
||||
query_filters.append(["docstatus", "=", 1])
|
||||
try:
|
||||
return (
|
||||
frappe.db.get_all(
|
||||
"Sales Invoice",
|
||||
filters=query_filters,
|
||||
fields=[{"SUM": "tourist_tax_return"}],
|
||||
as_list=True,
|
||||
limit=1,
|
||||
)[0][0]
|
||||
or 0
|
||||
)
|
||||
except (IndexError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
@_cached
|
||||
def get_zero_rated_total(filters):
|
||||
"""Returns the sum of each Sales Invoice Item Amount which is zero rated."""
|
||||
return _sum_item_field(
|
||||
"Sales Invoice",
|
||||
"Sales Invoice Item",
|
||||
"base_net_amount",
|
||||
filters,
|
||||
extra_item_where=lambda i: [i.is_zero_rated == 1],
|
||||
i = frappe.qb.DocType("Sales Invoice Item")
|
||||
s = frappe.qb.DocType("Sales Invoice")
|
||||
query = (
|
||||
frappe.qb.from_(i)
|
||||
.inner_join(s)
|
||||
.on(i.parent == s.name)
|
||||
.select(Sum(i.base_net_amount).as_("total"))
|
||||
.where((s.docstatus == 1) & (i.is_zero_rated == 1))
|
||||
)
|
||||
for condition in get_conditions(filters, s):
|
||||
query = query.where(condition)
|
||||
try:
|
||||
return query.run()[0][0] or 0
|
||||
except (IndexError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
@_cached
|
||||
def get_exempt_total(filters):
|
||||
"""Returns the sum of each Sales Invoice Item Amount which is Vat Exempt."""
|
||||
return _sum_item_field(
|
||||
"Sales Invoice",
|
||||
"Sales Invoice Item",
|
||||
"base_net_amount",
|
||||
filters,
|
||||
extra_item_where=lambda i: [i.is_exempt == 1],
|
||||
i = frappe.qb.DocType("Sales Invoice Item")
|
||||
s = frappe.qb.DocType("Sales Invoice")
|
||||
query = (
|
||||
frappe.qb.from_(i)
|
||||
.inner_join(s)
|
||||
.on(i.parent == s.name)
|
||||
.select(Sum(i.base_net_amount).as_("total"))
|
||||
.where((s.docstatus == 1) & (i.is_exempt == 1))
|
||||
)
|
||||
for condition in get_conditions(filters, s):
|
||||
query = query.where(condition)
|
||||
try:
|
||||
return query.run()[0][0] or 0
|
||||
except (IndexError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
def get_conditions(filters, s):
|
||||
"""The conditions to be used to filter data to calculate the total sale."""
|
||||
conditions = []
|
||||
if filters.get("company"):
|
||||
conditions.append(s.company == filters.get("company"))
|
||||
if filters.get("from_date"):
|
||||
conditions.append(s.posting_date >= filters.get("from_date"))
|
||||
if filters.get("to_date"):
|
||||
conditions.append(s.posting_date <= filters.get("to_date"))
|
||||
return conditions
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
// 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Coalesce, Sum
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
if not filters:
|
||||
filters = {}
|
||||
columns = get_columns(filters)
|
||||
data = get_data(filters)
|
||||
return columns, data
|
||||
|
||||
|
||||
def get_columns(filters):
|
||||
doc_type = filters.get("doc_type") or "Sales Invoice"
|
||||
is_sales = doc_type == "Sales Invoice"
|
||||
item_wise = bool(filters.get("item_wise"))
|
||||
|
||||
columns = [
|
||||
{
|
||||
"label": _("Invoice"),
|
||||
"fieldname": "name",
|
||||
"fieldtype": "Link",
|
||||
"options": doc_type,
|
||||
"width": 180,
|
||||
},
|
||||
{"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 100},
|
||||
{
|
||||
"label": _("Customer") if is_sales else _("Supplier"),
|
||||
"fieldname": "party",
|
||||
"fieldtype": "Link",
|
||||
"options": "Customer" if is_sales else "Supplier",
|
||||
"width": 150,
|
||||
},
|
||||
{
|
||||
"label": _("Cost Center"),
|
||||
"fieldname": "cost_center",
|
||||
"fieldtype": "Link",
|
||||
"options": "Cost Center",
|
||||
"width": 130,
|
||||
},
|
||||
]
|
||||
if is_sales:
|
||||
columns.append({"label": _("Emirate"), "fieldname": "emirate", "fieldtype": "Data", "width": 110})
|
||||
else:
|
||||
columns.append(
|
||||
{
|
||||
"label": _("Reverse Charge"),
|
||||
"fieldname": "reverse_charge",
|
||||
"fieldtype": "Data",
|
||||
"width": 110,
|
||||
}
|
||||
)
|
||||
if item_wise:
|
||||
columns.extend(
|
||||
[
|
||||
{
|
||||
"label": _("Item Code"),
|
||||
"fieldname": "item_code",
|
||||
"fieldtype": "Link",
|
||||
"options": "Item",
|
||||
"width": 150,
|
||||
},
|
||||
{"label": _("Qty"), "fieldname": "qty", "fieldtype": "Float", "width": 80},
|
||||
{"label": _("Rate"), "fieldname": "rate", "fieldtype": "Currency", "width": 100},
|
||||
]
|
||||
)
|
||||
else:
|
||||
columns.append({"label": _("Qty"), "fieldname": "qty", "fieldtype": "Float", "width": 80})
|
||||
columns.extend(
|
||||
[
|
||||
{"label": _("Net Amount"), "fieldname": "net_amount", "fieldtype": "Currency", "width": 120},
|
||||
{"label": _("VAT Amount"), "fieldname": "vat_amount", "fieldtype": "Currency", "width": 120},
|
||||
{
|
||||
"label": _("Total Amount"),
|
||||
"fieldname": "total_amount",
|
||||
"fieldtype": "Currency",
|
||||
"width": 120,
|
||||
},
|
||||
]
|
||||
)
|
||||
return columns
|
||||
|
||||
|
||||
def get_data(filters):
|
||||
doc_type = filters.get("doc_type") or "Sales Invoice"
|
||||
if doc_type == "Sales Invoice":
|
||||
return _fetch_rows(filters, is_sales=True)
|
||||
if doc_type == "Purchase Invoice":
|
||||
return _fetch_rows(filters, is_sales=False)
|
||||
return []
|
||||
|
||||
|
||||
def _fetch_rows(filters, is_sales):
|
||||
"""Build the VAT register query for either Sales or Purchase Invoices.
|
||||
|
||||
Item-wise mode returns one row per Sales/Purchase Invoice Item; the
|
||||
default mode aggregates back to one row per invoice with summed qty,
|
||||
net, VAT, and total. ``COALESCE(i.tax_amount, 0)`` is used everywhere
|
||||
so a missing VAT amount surfaces as 0 instead of NULL — matching the
|
||||
currency display and avoiding NULLs in client-side totals.
|
||||
"""
|
||||
parent_doctype = "Sales Invoice" if is_sales else "Purchase Invoice"
|
||||
child_doctype = "Sales Invoice Item" if is_sales else "Purchase Invoice Item"
|
||||
parent = frappe.qb.DocType(parent_doctype)
|
||||
child = frappe.qb.DocType(child_doctype)
|
||||
item_wise = bool(filters.get("item_wise"))
|
||||
|
||||
party_field = parent.customer if is_sales else parent.supplier
|
||||
party_extra = parent.vat_emirate.as_("emirate") if is_sales else parent.reverse_charge
|
||||
|
||||
tax_amount = Coalesce(child.tax_amount, 0)
|
||||
gross = child.base_net_amount + tax_amount
|
||||
|
||||
if item_wise:
|
||||
query = (
|
||||
frappe.qb.from_(parent)
|
||||
.inner_join(child)
|
||||
.on(child.parent == parent.name)
|
||||
.where(parent.docstatus == 1)
|
||||
.select(
|
||||
parent.name,
|
||||
parent.posting_date,
|
||||
party_field.as_("party"),
|
||||
Coalesce(child.cost_center, parent.cost_center).as_("cost_center"),
|
||||
party_extra,
|
||||
child.item_code,
|
||||
child.qty,
|
||||
child.rate,
|
||||
child.base_net_amount.as_("net_amount"),
|
||||
tax_amount.as_("vat_amount"),
|
||||
gross.as_("total_amount"),
|
||||
)
|
||||
.orderby(parent.posting_date)
|
||||
.orderby(parent.name)
|
||||
.orderby(child.idx)
|
||||
)
|
||||
else:
|
||||
cost_center = parent.cost_center
|
||||
query = (
|
||||
frappe.qb.from_(parent)
|
||||
.inner_join(child)
|
||||
.on(child.parent == parent.name)
|
||||
.where(parent.docstatus == 1)
|
||||
.select(
|
||||
parent.name,
|
||||
parent.posting_date,
|
||||
party_field.as_("party"),
|
||||
cost_center,
|
||||
party_extra,
|
||||
Sum(child.qty).as_("qty"),
|
||||
Coalesce(Sum(child.base_net_amount), 0).as_("net_amount"),
|
||||
Coalesce(Sum(tax_amount), 0).as_("vat_amount"),
|
||||
Coalesce(Sum(gross), 0).as_("total_amount"),
|
||||
)
|
||||
.groupby(parent.name, parent.posting_date, party_field, cost_center, party_extra)
|
||||
.orderby(parent.posting_date)
|
||||
.orderby(parent.name)
|
||||
)
|
||||
|
||||
query = _apply_period_filters(query, parent, filters)
|
||||
|
||||
if is_sales and filters.get("vat"):
|
||||
query = query.where(parent.vat_emirate == filters["vat"])
|
||||
if not is_sales and filters.get("reverse_charge") in ("Y", "N"):
|
||||
query = query.where(parent.reverse_charge == filters["reverse_charge"])
|
||||
if is_sales:
|
||||
category_criterion = _sales_category_criterion(child, filters.get("category"))
|
||||
if category_criterion is not None:
|
||||
query = query.where(category_criterion)
|
||||
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
def _apply_period_filters(query, parent, filters):
|
||||
if filters.get("company"):
|
||||
query = query.where(parent.company == filters["company"])
|
||||
if filters.get("from_date"):
|
||||
query = query.where(parent.posting_date >= filters["from_date"])
|
||||
if filters.get("to_date"):
|
||||
query = query.where(parent.posting_date <= filters["to_date"])
|
||||
return query
|
||||
|
||||
|
||||
def _sales_category_criterion(child, category):
|
||||
"""Translate the ``category`` filter into a Sales Invoice Item criterion."""
|
||||
if category == "Standard":
|
||||
return (child.is_zero_rated != 1) & (child.is_exempt != 1)
|
||||
if category == "Zero Rated":
|
||||
return child.is_zero_rated == 1
|
||||
if category == "Exempt Rated":
|
||||
return child.is_exempt == 1
|
||||
return None
|
||||
@@ -200,14 +200,6 @@ 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