Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

View File

@@ -0,0 +1,481 @@
"""
Fusion Accounting - SAF-T Export
Generates Standard Audit File for Tax (SAF-T) XML documents that
conform to the OECD SAF-T Schema v2.0. The export wizard collects a
date range from the user, queries general-ledger accounts, partners,
tax tables and journal entries for the selected period, and builds a
compliant XML tree.
The default namespace is the Portuguese SAF-T variant
(urn:OECD:StandardAuditFile-Tax:PT_1.04_01) but can be overridden per
company through the ``saft_namespace`` field so that the same generator
serves multiple country-specific schemas.
Reference: OECD Standard Audit File Tax 2.0 (2010).
Original implementation by Nexa Systems Inc.
"""
import base64
import io
import logging
import xml.etree.ElementTree as ET
from datetime import date
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
_log = logging.getLogger(__name__)
# Default namespace when not overridden on the company
_DEFAULT_SAFT_NS = "urn:OECD:StandardAuditFile-Tax:PT_1.04_01"
_SAFT_VERSION = "2.00_01"
class FusionSAFTExport(models.TransientModel):
"""
Wizard that produces an SAF-T XML file for a given date range.
Workflow
--------
1. The user opens the wizard from *Accounting ▸ Reports ▸ Fiscal
Compliance ▸ SAF-T Export*.
2. A date range and optional filters are selected.
3. Clicking **Generate** triggers ``action_generate_saft`` which
delegates to ``generate_saft_xml()``.
4. The resulting XML is stored as an ``ir.attachment`` and a download
action is returned.
The XML tree follows the OECD SAF-T structure:
- ``<Header>``
- ``<MasterFiles>`` (accounts, customers, suppliers, tax table)
- ``<GeneralLedgerEntries>`` (journals → transactions → lines)
"""
_name = "fusion.saft.export"
_description = "SAF-T Export Wizard"
# ------------------------------------------------------------------
# Fields
# ------------------------------------------------------------------
date_from = fields.Date(
string="Start Date",
required=True,
default=lambda self: date(date.today().year, 1, 1),
help="First day of the reporting period (inclusive).",
)
date_to = fields.Date(
string="End Date",
required=True,
default=fields.Date.context_today,
help="Last day of the reporting period (inclusive).",
)
company_id = fields.Many2one(
comodel_name="res.company",
string="Company",
required=True,
default=lambda self: self.env.company,
)
state = fields.Selection(
selection=[
("draft", "Draft"),
("done", "Generated"),
("error", "Error"),
],
string="Status",
default="draft",
readonly=True,
)
attachment_id = fields.Many2one(
comodel_name="ir.attachment",
string="Generated File",
readonly=True,
ondelete="set null",
)
saft_namespace = fields.Char(
string="SAF-T Namespace",
default=_DEFAULT_SAFT_NS,
help=(
"XML namespace written into the root element. Change this "
"to match your country's SAF-T variant."
),
)
error_message = fields.Text(
string="Error Details",
readonly=True,
)
# ------------------------------------------------------------------
# Validation
# ------------------------------------------------------------------
@api.constrains("date_from", "date_to")
def _check_date_range(self):
for record in self:
if record.date_from and record.date_to and record.date_to < record.date_from:
raise ValidationError(
_("The end date must not precede the start date.")
)
# ------------------------------------------------------------------
# Public action
# ------------------------------------------------------------------
def action_generate_saft(self):
"""Entry point called by the wizard button. Generates the XML and
stores the result as a downloadable attachment."""
self.ensure_one()
try:
xml_bytes = self.generate_saft_xml()
filename = (
f"SAF-T_{self.company_id.name}_{self.date_from}_{self.date_to}.xml"
)
attachment = self.env["ir.attachment"].create({
"name": filename,
"type": "binary",
"datas": base64.b64encode(xml_bytes),
"res_model": self._name,
"res_id": self.id,
"mimetype": "application/xml",
})
self.write({
"state": "done",
"attachment_id": attachment.id,
"error_message": False,
})
return {
"type": "ir.actions.act_url",
"url": f"/web/content/{attachment.id}?download=true",
"target": "new",
}
except Exception as exc:
_log.exception("SAF-T generation failed for company %s", self.company_id.name)
self.write({
"state": "error",
"error_message": str(exc),
})
raise UserError(
_("SAF-T generation failed:\n%(error)s", error=exc)
) from exc
# ------------------------------------------------------------------
# XML generation
# ------------------------------------------------------------------
def generate_saft_xml(self):
"""Build the full SAF-T XML document and return it as ``bytes``.
The tree mirrors the OECD SAF-T v2.0 specification:
.. code-block:: xml
<AuditFile xmlns="...">
<Header>...</Header>
<MasterFiles>
<GeneralLedgerAccounts>...</GeneralLedgerAccounts>
<Customers>...</Customers>
<Suppliers>...</Suppliers>
<TaxTable>...</TaxTable>
</MasterFiles>
<GeneralLedgerEntries>
<Journal>
<Transaction>
<Line>...</Line>
</Transaction>
</Journal>
</GeneralLedgerEntries>
</AuditFile>
"""
self.ensure_one()
ns = self.saft_namespace or _DEFAULT_SAFT_NS
root = ET.Element("AuditFile", xmlns=ns)
self._build_header(root)
master_files = ET.SubElement(root, "MasterFiles")
self._build_general_ledger_accounts(master_files)
self._build_customers(master_files)
self._build_suppliers(master_files)
self._build_tax_table(master_files)
self._build_general_ledger_entries(root)
# Serialise to bytes with XML declaration
buf = io.BytesIO()
tree = ET.ElementTree(root)
ET.indent(tree, space=" ")
tree.write(buf, encoding="UTF-8", xml_declaration=True)
return buf.getvalue()
# ------------------------------------------------------------------
# Header
# ------------------------------------------------------------------
def _build_header(self, root):
"""Populate the ``<Header>`` element with company metadata."""
header = ET.SubElement(root, "Header")
company = self.company_id
_add_text(header, "AuditFileVersion", _SAFT_VERSION)
_add_text(header, "CompanyID", company.company_registry or company.vat or str(company.id))
_add_text(header, "TaxRegistrationNumber", company.vat or "")
_add_text(header, "TaxAccountingBasis", "F") # F = Facturação / Invoicing
_add_text(header, "CompanyName", company.name or "")
# Company address block
address = ET.SubElement(header, "CompanyAddress")
_add_text(address, "StreetName", company.street or "")
_add_text(address, "City", company.city or "")
_add_text(address, "PostalCode", company.zip or "")
_add_text(address, "Country", (company.country_id.code or "").upper())
_add_text(header, "FiscalYear", str(self.date_from.year))
_add_text(header, "StartDate", str(self.date_from))
_add_text(header, "EndDate", str(self.date_to))
_add_text(header, "CurrencyCode", (company.currency_id.name or "EUR").upper())
_add_text(header, "DateCreated", str(fields.Date.context_today(self)))
_add_text(header, "TaxEntity", company.name or "")
_add_text(header, "ProductCompanyTaxID", company.vat or "")
_add_text(header, "SoftwareCertificateNumber", "0")
_add_text(header, "ProductID", "Fusion Accounting/Nexa Systems Inc.")
_add_text(header, "ProductVersion", "19.0")
# ------------------------------------------------------------------
# MasterFiles General Ledger Accounts
# ------------------------------------------------------------------
def _build_general_ledger_accounts(self, master_files):
"""Add every active account for the company."""
accounts = self.env["account.account"].search([
("company_id", "=", self.company_id.id),
])
if not accounts:
return
gl_section = ET.SubElement(master_files, "GeneralLedgerAccounts")
for account in accounts:
acct_el = ET.SubElement(gl_section, "Account")
_add_text(acct_el, "AccountID", account.code or "")
_add_text(acct_el, "AccountDescription", account.name or "")
# Map Odoo account types to SAF-T grouping categories
saft_type = self._resolve_saft_account_type(account)
_add_text(acct_el, "GroupingCategory", saft_type)
_add_text(acct_el, "GroupingCode", account.code[:2] if account.code else "00")
# Opening and closing balances for the period
opening, closing = self._compute_account_balances(account)
_add_text(acct_el, "OpeningDebitBalance", f"{max(opening, 0):.2f}")
_add_text(acct_el, "OpeningCreditBalance", f"{max(-opening, 0):.2f}")
_add_text(acct_el, "ClosingDebitBalance", f"{max(closing, 0):.2f}")
_add_text(acct_el, "ClosingCreditBalance", f"{max(-closing, 0):.2f}")
def _resolve_saft_account_type(self, account):
"""Map an ``account.account`` to an SAF-T grouping category string.
Returns one of ``GR`` (Revenue/Income), ``GP`` (Expense),
``GA`` (Asset), ``GL`` (Liability), or ``GM`` (Mixed/Other).
"""
account_type = account.account_type or ""
if "receivable" in account_type or "income" in account_type:
return "GR"
if "payable" in account_type or "expense" in account_type:
return "GP"
if "asset" in account_type or "bank" in account_type or "cash" in account_type:
return "GA"
if "liability" in account_type:
return "GL"
return "GM"
def _compute_account_balances(self, account):
"""Return ``(opening_balance, closing_balance)`` for *account*
within the wizard's date range.
* ``opening_balance`` net balance of all posted move-lines
dated **before** ``date_from``.
* ``closing_balance`` opening balance plus net movement
between ``date_from`` and ``date_to``.
"""
MoveLines = self.env["account.move.line"]
domain_base = [
("account_id", "=", account.id),
("parent_state", "=", "posted"),
("company_id", "=", self.company_id.id),
]
# Opening balance: everything before the period
opening_lines = MoveLines.search(
domain_base + [("date", "<", self.date_from)]
)
opening = sum(opening_lines.mapped("debit")) - sum(opening_lines.mapped("credit"))
# Movement during the period
period_lines = MoveLines.search(
domain_base + [
("date", ">=", self.date_from),
("date", "<=", self.date_to),
]
)
movement = sum(period_lines.mapped("debit")) - sum(period_lines.mapped("credit"))
return opening, opening + movement
# ------------------------------------------------------------------
# MasterFiles Customers
# ------------------------------------------------------------------
def _build_customers(self, master_files):
"""Export customer (receivable) partners referenced in the period."""
partners = self._get_partners_for_type("asset_receivable")
if not partners:
return
customers_el = ET.SubElement(master_files, "Customers")
for partner in partners:
cust = ET.SubElement(customers_el, "Customer")
_add_text(cust, "CustomerID", str(partner.id))
_add_text(cust, "CustomerTaxID", partner.vat or "999999990")
_add_text(cust, "CompanyName", partner.name or "")
address = ET.SubElement(cust, "BillingAddress")
_add_text(address, "StreetName", partner.street or "Desconhecido")
_add_text(address, "City", partner.city or "Desconhecido")
_add_text(address, "PostalCode", partner.zip or "0000-000")
_add_text(address, "Country", (partner.country_id.code or "").upper() or "PT")
# ------------------------------------------------------------------
# MasterFiles Suppliers
# ------------------------------------------------------------------
def _build_suppliers(self, master_files):
"""Export supplier (payable) partners referenced in the period."""
partners = self._get_partners_for_type("liability_payable")
if not partners:
return
suppliers_el = ET.SubElement(master_files, "Suppliers")
for partner in partners:
supp = ET.SubElement(suppliers_el, "Supplier")
_add_text(supp, "SupplierID", str(partner.id))
_add_text(supp, "SupplierTaxID", partner.vat or "999999990")
_add_text(supp, "CompanyName", partner.name or "")
address = ET.SubElement(supp, "SupplierAddress")
_add_text(address, "StreetName", partner.street or "Desconhecido")
_add_text(address, "City", partner.city or "Desconhecido")
_add_text(address, "PostalCode", partner.zip or "0000-000")
_add_text(address, "Country", (partner.country_id.code or "").upper() or "PT")
def _get_partners_for_type(self, account_type):
"""Return distinct partners that have posted move-lines on
accounts of the given ``account_type`` within the period.
"""
lines = self.env["account.move.line"].search([
("company_id", "=", self.company_id.id),
("parent_state", "=", "posted"),
("date", ">=", self.date_from),
("date", "<=", self.date_to),
("account_id.account_type", "=", account_type),
("partner_id", "!=", False),
])
return lines.mapped("partner_id")
# ------------------------------------------------------------------
# MasterFiles Tax Table
# ------------------------------------------------------------------
def _build_tax_table(self, master_files):
"""Export the company's active taxes."""
taxes = self.env["account.tax"].search([
("company_id", "=", self.company_id.id),
("active", "=", True),
])
if not taxes:
return
table_el = ET.SubElement(master_files, "TaxTable")
for tax in taxes:
entry = ET.SubElement(table_el, "TaxTableEntry")
_add_text(entry, "TaxType", self._resolve_saft_tax_type(tax))
_add_text(entry, "TaxCountryRegion", (self.company_id.country_id.code or "PT").upper())
_add_text(entry, "TaxCode", tax.name or str(tax.id))
_add_text(entry, "Description", tax.description or tax.name or "")
_add_text(entry, "TaxPercentage", f"{tax.amount:.2f}")
@staticmethod
def _resolve_saft_tax_type(tax):
"""Derive an SAF-T TaxType from the Odoo tax type_tax_use.
Returns ``IVA`` for sales/purchase VAT and ``IS`` for
withholding / other types.
"""
if tax.type_tax_use in ("sale", "purchase"):
return "IVA"
return "IS"
# ------------------------------------------------------------------
# GeneralLedgerEntries
# ------------------------------------------------------------------
def _build_general_ledger_entries(self, root):
"""Write all posted journal entries for the period, grouped by
journal (one ``<Journal>`` per Odoo ``account.journal``).
"""
entries_el = ET.SubElement(root, "GeneralLedgerEntries")
moves = self.env["account.move"].search([
("company_id", "=", self.company_id.id),
("state", "=", "posted"),
("date", ">=", self.date_from),
("date", "<=", self.date_to),
], order="journal_id, date, id")
# Summary totals
total_debit = 0.0
total_credit = 0.0
# Group moves by journal
journals_map = {}
for move in moves:
journals_map.setdefault(move.journal_id, self.env["account.move"])
journals_map[move.journal_id] |= move
_add_text(entries_el, "NumberOfEntries", str(len(moves)))
for journal, journal_moves in journals_map.items():
journal_el = ET.SubElement(entries_el, "Journal")
_add_text(journal_el, "JournalID", journal.code or str(journal.id))
_add_text(journal_el, "Description", journal.name or "")
for move in journal_moves:
txn_el = ET.SubElement(journal_el, "Transaction")
_add_text(txn_el, "TransactionID", move.name or str(move.id))
_add_text(txn_el, "Period", str(move.date.month))
_add_text(txn_el, "TransactionDate", str(move.date))
_add_text(txn_el, "SourceID", (move.create_uid.login or "") if move.create_uid else "")
_add_text(txn_el, "Description", move.ref or move.name or "")
_add_text(txn_el, "GLPostingDate", str(move.date))
for line in move.line_ids:
line_el = ET.SubElement(txn_el, "Line")
_add_text(line_el, "RecordID", str(line.id))
_add_text(line_el, "AccountID", line.account_id.code or "")
_add_text(line_el, "SourceDocumentID", move.name or "")
if line.debit:
debit_el = ET.SubElement(line_el, "DebitAmount")
_add_text(debit_el, "Amount", f"{line.debit:.2f}")
total_debit += line.debit
else:
credit_el = ET.SubElement(line_el, "CreditAmount")
_add_text(credit_el, "Amount", f"{line.credit:.2f}")
total_credit += line.credit
_add_text(line_el, "Description", line.name or "")
_add_text(entries_el, "TotalDebit", f"{total_debit:.2f}")
_add_text(entries_el, "TotalCredit", f"{total_credit:.2f}")
# ======================================================================
# Module-level helpers
# ======================================================================
def _add_text(parent, tag, text):
"""Create a child element with the given *tag* and *text* value."""
el = ET.SubElement(parent, tag)
el.text = str(text) if text is not None else ""
return el