using System.Text; using System.Xml; using Books.Api.Saft.Models; namespace Books.Api.Saft.Services; /// /// Builds SAF-T DK compliant XML documents. /// Based on OECD SAF-T 2.0 with Danish customizations. /// public class SaftXmlBuilder { private const string SaftNamespace = "urn:OECD:StandardAuditFile-Taxation/2.00"; /// /// Builds a SAF-T XML document from the provided data. /// public string Build(SaftDocument document) { var settings = new XmlWriterSettings { Indent = true, IndentChars = " ", Encoding = new UTF8Encoding(false), // UTF-8 without BOM OmitXmlDeclaration = false }; using var stream = new MemoryStream(); using (var writer = XmlWriter.Create(stream, settings)) { writer.WriteStartDocument(); writer.WriteStartElement("AuditFile", SaftNamespace); WriteHeader(writer, document.Header); WriteMasterFiles(writer, document.MasterFiles); WriteGeneralLedgerEntries(writer, document.Entries); writer.WriteEndElement(); // AuditFile writer.WriteEndDocument(); } return Encoding.UTF8.GetString(stream.ToArray()); } private static void WriteHeader(XmlWriter writer, SaftHeader header) { writer.WriteStartElement("Header"); writer.WriteElementString("AuditFileVersion", header.AuditFileVersion); writer.WriteElementString("AuditFileCountry", "DK"); writer.WriteElementString("AuditFileDateCreated", header.AuditFileDateCreated); writer.WriteElementString("SoftwareCompanyName", header.SoftwareCompanyName); writer.WriteElementString("SoftwareID", header.SoftwareID); writer.WriteElementString("SoftwareVersion", header.SoftwareVersion); // SAF-T requires DefaultCurrencyCode (ISO 4217) writer.WriteElementString("DefaultCurrencyCode", "DKK"); WriteCompany(writer, header.Company); WriteSelectionCriteria(writer, header.SelectionCriteria); // SAF-T requires TaxAccountingBasis: A = Accrual, C = Cash, O = Other writer.WriteElementString("TaxAccountingBasis", "A"); writer.WriteEndElement(); // Header } private static void WriteCompany(XmlWriter writer, SaftCompany company) { writer.WriteStartElement("Company"); writer.WriteElementString("RegistrationNumber", company.RegistrationNumber); writer.WriteElementString("Name", company.Name); WriteAddress(writer, company.Address); if (company.Contact != null) { WriteContact(writer, company.Contact); } // SAF-T DK requires TaxRegistrationNumber with "DK" prefix + CVR if (!string.IsNullOrEmpty(company.RegistrationNumber)) { writer.WriteElementString("TaxRegistrationNumber", "DK" + company.RegistrationNumber); } writer.WriteEndElement(); // Company } private static void WriteAddress(XmlWriter writer, SaftAddress address) { writer.WriteStartElement("Address"); if (!string.IsNullOrEmpty(address.StreetName)) writer.WriteElementString("StreetName", address.StreetName); if (!string.IsNullOrEmpty(address.City)) writer.WriteElementString("City", address.City); if (!string.IsNullOrEmpty(address.PostalCode)) writer.WriteElementString("PostalCode", address.PostalCode); writer.WriteElementString("Country", address.Country); writer.WriteEndElement(); // Address } private static void WriteContact(XmlWriter writer, SaftContact contact) { writer.WriteStartElement("Contact"); if (!string.IsNullOrEmpty(contact.Telephone)) writer.WriteElementString("Telephone", contact.Telephone); if (!string.IsNullOrEmpty(contact.Email)) writer.WriteElementString("Email", contact.Email); if (!string.IsNullOrEmpty(contact.Website)) writer.WriteElementString("Website", contact.Website); writer.WriteEndElement(); // Contact } private static void WriteSelectionCriteria(XmlWriter writer, SaftSelectionCriteria criteria) { writer.WriteStartElement("SelectionCriteria"); writer.WriteElementString("PeriodStart", criteria.PeriodStart); writer.WriteElementString("PeriodEnd", criteria.PeriodEnd); writer.WriteEndElement(); // SelectionCriteria } private static void WriteMasterFiles(XmlWriter writer, SaftMasterFiles masterFiles) { writer.WriteStartElement("MasterFiles"); WriteGeneralLedgerAccounts(writer, masterFiles.GeneralLedgerAccounts); WriteCustomers(writer, masterFiles.Customers); WriteSuppliers(writer, masterFiles.Suppliers); WriteTaxTable(writer); writer.WriteEndElement(); // MasterFiles } /// /// Writes the TaxTable element declaring all Danish VAT codes. /// Required by SAF-T schema to describe the tax codes used in transactions. /// private static void WriteTaxTable(XmlWriter writer) { writer.WriteStartElement("TaxTable"); writer.WriteStartElement("TaxTableEntry"); writer.WriteElementString("TaxType", "MVA"); writer.WriteElementString("Description", "Dansk moms (VAT)"); // Output VAT codes WriteTaxCodeDetails(writer, "U25", "Udgående moms 25%", 25.00m); WriteTaxCodeDetails(writer, "UEU", "EU-salg (momsfrit)", 0.00m); WriteTaxCodeDetails(writer, "UEXP", "Eksport uden for EU (momsfrit)", 0.00m); // Input VAT codes WriteTaxCodeDetails(writer, "I25", "Indgående moms 25%", 25.00m); WriteTaxCodeDetails(writer, "IEUV", "EU-erhvervelse varer (reverse charge)", 25.00m); WriteTaxCodeDetails(writer, "IEUY", "EU-erhvervelse ydelser (reverse charge)", 25.00m); WriteTaxCodeDetails(writer, "IVV", "Import varer fra verden", 0.00m); WriteTaxCodeDetails(writer, "IVY", "Import ydelser fra verden (reverse charge)", 25.00m); // Special codes WriteTaxCodeDetails(writer, "REP", "Repræsentation (25% fradrag)", 25.00m); WriteTaxCodeDetails(writer, "INGEN", "Ingen moms", 0.00m); writer.WriteEndElement(); // TaxTableEntry writer.WriteEndElement(); // TaxTable } private static void WriteTaxCodeDetails(XmlWriter writer, string taxCode, string description, decimal taxPercentage) { writer.WriteStartElement("TaxCodeDetails"); writer.WriteElementString("TaxCode", taxCode); writer.WriteElementString("Description", description); writer.WriteElementString("TaxPercentage", FormatDecimal(taxPercentage)); writer.WriteEndElement(); // TaxCodeDetails } private static void WriteGeneralLedgerAccounts(XmlWriter writer, List accounts) { if (accounts.Count == 0) return; writer.WriteStartElement("GeneralLedgerAccounts"); // SAF-T DK 2027 requirement: StandardAccount metadata // Ref: BEK nr. 97 af 26/01/2023, Erhvervsstyrelsen writer.WriteElementString("NameOfStandardAccount", "Standardkontoplan"); writer.WriteElementString("VersionOfStandardAccount", "20230131"); foreach (var account in accounts) { writer.WriteStartElement("Account"); writer.WriteElementString("AccountID", account.AccountID); writer.WriteElementString("AccountDescription", account.AccountDescription); if (!string.IsNullOrEmpty(account.StandardAccountID)) writer.WriteElementString("StandardAccountID", account.StandardAccountID); writer.WriteElementString("AccountType", account.AccountType); // Opening balances (SAF-T schema requires these elements even when zero) writer.WriteElementString("OpeningDebitBalance", FormatDecimal(account.OpeningDebitBalance)); writer.WriteElementString("OpeningCreditBalance", FormatDecimal(account.OpeningCreditBalance)); // Closing balances (SAF-T schema requires these elements even when zero) writer.WriteElementString("ClosingDebitBalance", FormatDecimal(account.ClosingDebitBalance)); writer.WriteElementString("ClosingCreditBalance", FormatDecimal(account.ClosingCreditBalance)); writer.WriteEndElement(); // Account } writer.WriteEndElement(); // GeneralLedgerAccounts } private static void WriteCustomers(XmlWriter writer, List customers) { if (customers.Count == 0) return; writer.WriteStartElement("Customers"); foreach (var customer in customers) { writer.WriteStartElement("Customer"); writer.WriteElementString("CustomerID", customer.CustomerID); if (!string.IsNullOrEmpty(customer.AccountID)) writer.WriteElementString("AccountID", customer.AccountID); writer.WriteElementString("Name", customer.Name); if (!string.IsNullOrEmpty(customer.RegistrationNumber)) writer.WriteElementString("RegistrationNumber", customer.RegistrationNumber); if (customer.Address != null) WriteAddress(writer, customer.Address); if (customer.Contact != null) WriteContact(writer, customer.Contact); writer.WriteEndElement(); // Customer } writer.WriteEndElement(); // Customers } private static void WriteSuppliers(XmlWriter writer, List suppliers) { if (suppliers.Count == 0) return; writer.WriteStartElement("Suppliers"); foreach (var supplier in suppliers) { writer.WriteStartElement("Supplier"); writer.WriteElementString("SupplierID", supplier.SupplierID); if (!string.IsNullOrEmpty(supplier.AccountID)) writer.WriteElementString("AccountID", supplier.AccountID); writer.WriteElementString("Name", supplier.Name); if (!string.IsNullOrEmpty(supplier.RegistrationNumber)) writer.WriteElementString("RegistrationNumber", supplier.RegistrationNumber); if (supplier.Address != null) WriteAddress(writer, supplier.Address); if (supplier.Contact != null) WriteContact(writer, supplier.Contact); writer.WriteEndElement(); // Supplier } writer.WriteEndElement(); // Suppliers } private static void WriteGeneralLedgerEntries(XmlWriter writer, SaftGeneralLedgerEntries entries) { writer.WriteStartElement("GeneralLedgerEntries"); writer.WriteElementString("NumberOfEntries", entries.NumberOfEntries.ToString()); writer.WriteElementString("TotalDebit", FormatDecimal(entries.TotalDebit)); writer.WriteElementString("TotalCredit", FormatDecimal(entries.TotalCredit)); foreach (var journal in entries.Journals) { WriteJournal(writer, journal); } writer.WriteEndElement(); // GeneralLedgerEntries } private static void WriteJournal(XmlWriter writer, SaftJournal journal) { writer.WriteStartElement("Journal"); writer.WriteElementString("JournalID", journal.JournalID); writer.WriteElementString("Description", journal.Description); writer.WriteElementString("Type", journal.Type); foreach (var transaction in journal.Transactions) { WriteTransaction(writer, transaction); } writer.WriteEndElement(); // Journal } private static void WriteTransaction(XmlWriter writer, SaftTransaction transaction) { writer.WriteStartElement("Transaction"); writer.WriteElementString("TransactionID", transaction.TransactionID); writer.WriteElementString("Period", transaction.Period); writer.WriteElementString("TransactionDate", transaction.TransactionDate); writer.WriteElementString("Description", transaction.Description); writer.WriteElementString("SystemEntryDate", transaction.SystemEntryDate); writer.WriteElementString("GLPostingDate", transaction.GLPostingDate); foreach (var line in transaction.Lines) { WriteTransactionLine(writer, line); } writer.WriteEndElement(); // Transaction } private static void WriteTransactionLine(XmlWriter writer, SaftTransactionLine line) { writer.WriteStartElement("Line"); writer.WriteElementString("RecordID", line.RecordID); writer.WriteElementString("AccountID", line.AccountID); if (!string.IsNullOrEmpty(line.Description)) writer.WriteElementString("Description", line.Description); // SAF-T schema requires at least one of DebitAmount or CreditAmount var hasDebit = line.DebitAmount.HasValue && line.DebitAmount.Value != 0; var hasCredit = line.CreditAmount.HasValue && line.CreditAmount.Value != 0; if (hasDebit) writer.WriteElementString("DebitAmount", FormatDecimal(line.DebitAmount!.Value)); if (hasCredit) writer.WriteElementString("CreditAmount", FormatDecimal(line.CreditAmount!.Value)); // If neither has a value, write a zero debit to satisfy schema requirement if (!hasDebit && !hasCredit) writer.WriteElementString("DebitAmount", FormatDecimal(0m)); if (!string.IsNullOrEmpty(line.CustomerID)) writer.WriteElementString("CustomerID", line.CustomerID); if (!string.IsNullOrEmpty(line.SupplierID)) writer.WriteElementString("SupplierID", line.SupplierID); if (line.TaxInfo != null) { WriteTaxInformation(writer, line.TaxInfo); } writer.WriteEndElement(); // Line } private static void WriteTaxInformation(XmlWriter writer, SaftTaxInformation taxInfo) { writer.WriteStartElement("TaxInformation"); writer.WriteElementString("TaxCode", taxInfo.TaxCode); if (taxInfo.TaxPercentage.HasValue) writer.WriteElementString("TaxPercentage", FormatDecimal(taxInfo.TaxPercentage.Value)); if (taxInfo.TaxBase.HasValue) writer.WriteElementString("TaxBase", FormatDecimal(taxInfo.TaxBase.Value)); writer.WriteElementString("TaxAmount", FormatDecimal(taxInfo.TaxAmount)); writer.WriteEndElement(); // TaxInformation } private static string FormatDecimal(decimal value) { // SAF-T requires decimal format with dot as decimal separator return value.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture); } }