""" 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: - ``
`` - ```` (accounts, customers, suppliers, tax table) - ```` (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
...
... ... ... ... ...
""" 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 ``
`` 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 ```` 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