using Books.Api.Domain; using Books.Api.EventFlow.ReadModels; using Books.Api.EventFlow.Repositories; using Books.Api.Saft.Models; using Ledger.Core.Models; using Ledger.Core.Services; using Microsoft.Extensions.Logging; namespace Books.Api.Saft.Services; /// /// Service for generating SAF-T (Standard Audit File for Tax) exports. /// Implements the Danish SAF-T DK standard based on OECD SAF-T 2.0. /// public class SaftExportService( ICompanyRepository companyRepository, IFiscalYearRepository fiscalYearRepository, IAccountRepository accountRepository, ICustomerRepository customerRepository, ILedgerService ledgerService, ILogger logger) : ISaftExportService { private const string AuditFileVersion = "1.0"; private const string SoftwareCompanyName = "Books ApS"; private const string SoftwareId = "books-api"; private const string SoftwareVersion = "1.0.0"; public async Task GenerateAsync( string companyId, string fiscalYearId, CancellationToken ct = default) { try { logger.LogInformation( "Generating SAF-T export for company {CompanyId}, fiscal year {FiscalYearId}", companyId, fiscalYearId); // 1. Load company data var company = await companyRepository.GetByIdAsync(companyId, ct); if (company == null) { return SaftExportResult.Error("COMPANY_NOT_FOUND", $"Company '{companyId}' not found"); } // Validate CVR number (required for SAF-T DK) if (string.IsNullOrWhiteSpace(company.Cvr) || !IsValidCvr(company.Cvr)) { return SaftExportResult.Error("INVALID_CVR", "Et gyldigt CVR-nummer (8 cifre) er påkrævet for SAF-T eksport. Opdater venligst virksomhedens CVR-nummer i indstillinger."); } // 2. Load fiscal year var fiscalYear = await fiscalYearRepository.GetByIdAsync(fiscalYearId, ct); if (fiscalYear == null) { return SaftExportResult.Error("FISCAL_YEAR_NOT_FOUND", $"Fiscal year '{fiscalYearId}' not found"); } if (fiscalYear.CompanyId != companyId) { return SaftExportResult.Error("FISCAL_YEAR_MISMATCH", "Fiscal year does not belong to the specified company"); } // 3. Load accounts var accounts = await accountRepository.GetByCompanyIdAsync(companyId, ct); // 4. Load customers var customers = await customerRepository.GetByCompanyIdAsync(companyId, ct); // 5. Query ledger entries for the fiscal year period var periodStart = new DateTimeOffset(fiscalYear.StartDate); var periodEnd = new DateTimeOffset(fiscalYear.EndDate.AddDays(1).AddTicks(-1)); // End of day var accountGuids = accounts .Select(a => ParseAccountGuid(a.Id)) .Where(g => g.HasValue) .Select(g => g!.Value) .ToList(); var ledgerEntries = new List(); var accountBalances = new Dictionary(); var openingBalances = new Dictionary(); if (accountGuids.Any()) { // Query individual entries (Aggregate=false) var entriesQuery = new EntriesQuery { AccountIds = accountGuids, From = periodStart, To = periodEnd, Aggregate = false, Limit = 100000 // High limit for full export }; var entriesResult = await ledgerService.QueryEntriesAsync(entriesQuery, ct); ledgerEntries = entriesResult.Entries?.ToList() ?? []; // Query aggregates for closing balances (within period) var aggregatesQuery = new EntriesQuery { AccountIds = accountGuids, From = periodStart, To = periodEnd, Aggregate = true }; var aggregatesResult = await ledgerService.QueryEntriesAsync(aggregatesQuery, ct); accountBalances = aggregatesResult.Aggregates? .ToDictionary(a => a.AccountId, a => a) ?? []; // Query historical aggregates for opening balances (all transactions BEFORE period start) var openingQuery = new EntriesQuery { AccountIds = accountGuids, To = periodStart.AddTicks(-1), // End just before period starts Aggregate = true }; var openingResult = await ledgerService.QueryEntriesAsync(openingQuery, ct); openingBalances = openingResult.Aggregates? .ToDictionary(a => a.AccountId, a => a) ?? []; } // 6. Build SAF-T document var document = BuildSaftDocument( company, fiscalYear, accounts.ToList(), customers.ToList(), ledgerEntries, accountBalances, openingBalances); // 7. Generate XML var xmlBuilder = new SaftXmlBuilder(); var xmlContent = xmlBuilder.Build(document); // 8. Generate filename var fileName = GenerateFileName(company, fiscalYear); logger.LogInformation( "SAF-T export generated successfully: {FileName}, {AccountCount} accounts, {EntryCount} entries", fileName, accounts.Count(), ledgerEntries.Count); return SaftExportResult.Ok(xmlContent, fileName); } catch (Exception ex) { logger.LogError(ex, "Failed to generate SAF-T export for company {CompanyId}", companyId); return SaftExportResult.Error("EXPORT_FAILED", ex.Message); } } private SaftDocument BuildSaftDocument( CompanyReadModelDto company, FiscalYearReadModelDto fiscalYear, List accounts, List customers, List ledgerEntries, Dictionary accountBalances, Dictionary openingBalances) { var header = BuildHeader(company, fiscalYear); var masterFiles = BuildMasterFiles(accounts, customers, accountBalances, openingBalances); var entries = BuildGeneralLedgerEntries(ledgerEntries, accounts); return new SaftDocument(header, masterFiles, entries); } private SaftHeader BuildHeader(CompanyReadModelDto company, FiscalYearReadModelDto fiscalYear) { var address = new SaftAddress( company.Address, company.City, company.PostalCode, company.Country); var saftCompany = new SaftCompany( company.Cvr ?? "", company.Name, address, null); // Contact info not stored yet var selectionCriteria = new SaftSelectionCriteria( fiscalYear.StartDate.ToString("yyyy-MM-dd"), fiscalYear.EndDate.ToString("yyyy-MM-dd")); return new SaftHeader( AuditFileVersion, DateTime.UtcNow.ToString("yyyy-MM-dd"), SoftwareCompanyName, SoftwareId, SoftwareVersion, saftCompany, selectionCriteria); } private SaftMasterFiles BuildMasterFiles( List accounts, List customers, Dictionary accountBalances, Dictionary openingBalances) { var saftAccounts = accounts.Select(acc => { var guid = ParseAccountGuid(acc.Id); accountBalances.TryGetValue(guid ?? Guid.Empty, out var periodBalance); openingBalances.TryGetValue(guid ?? Guid.Empty, out var openingBalance); // Compute net balance: debits - credits // SAF-T expects a single net balance reported as either DebitBalance or CreditBalance var openingNet = openingBalance.TotalDebits - openingBalance.TotalCredits; var openingDebit = openingNet >= 0 ? openingNet : 0m; var openingCredit = openingNet < 0 ? Math.Abs(openingNet) : 0m; // Closing balance = Opening net + Period net var closingNet = openingNet + (periodBalance.TotalDebits - periodBalance.TotalCredits); var closingDebit = closingNet >= 0 ? closingNet : 0m; var closingCredit = closingNet < 0 ? Math.Abs(closingNet) : 0m; return new SaftAccount( acc.AccountNumber, acc.Name, acc.StandardAccountNumber, MapAccountType(acc.AccountType), openingDebit, openingCredit, closingDebit, closingCredit); }).ToList(); var saftCustomers = customers.Select(cust => { var address = new SaftAddress( cust.Address, cust.City, cust.PostalCode, cust.Country); var contact = new SaftContact( cust.Phone, cust.Email, null); return new SaftCustomer( cust.CustomerNumber, null, // AccountID - could map to sub-ledger cust.Name, cust.Cvr, address, contact); }).ToList(); // Suppliers not implemented yet var suppliers = new List(); return new SaftMasterFiles(saftAccounts, saftCustomers, suppliers); } private SaftGeneralLedgerEntries BuildGeneralLedgerEntries( List ledgerEntries, List accounts) { // Group entries by End2EndId (transaction grouping) var transactionGroups = ledgerEntries .GroupBy(e => e.End2EndId) .OrderBy(g => g.Min(e => e.CreatedAt)); var accountLookup = accounts.ToDictionary( a => ParseAccountGuid(a.Id) ?? Guid.Empty, a => a.AccountNumber); var journals = new List(); var transactions = new List(); var totalDebit = 0m; var totalCredit = 0m; foreach (var group in transactionGroups) { var firstEntry = group.First(); var transactionDate = firstEntry.CreatedAt; var period = transactionDate.Month.ToString("D2"); var lines = group.Select((entry, idx) => { accountLookup.TryGetValue(entry.AccountId, out var accountNumber); var isDebit = entry.EntryType.Equals("Debit", StringComparison.OrdinalIgnoreCase); var debitAmount = isDebit ? entry.Amount : (decimal?)null; var creditAmount = !isDebit ? entry.Amount : (decimal?)null; if (isDebit) totalDebit += entry.Amount; else totalCredit += entry.Amount; // Try to extract VAT information from the entry description var taxInfo = ExtractTaxInformation(entry.Description, entry.Amount); return new SaftTransactionLine( (idx + 1).ToString(), accountNumber ?? entry.AccountId.ToString(), entry.Description, debitAmount, creditAmount, null, // CustomerID - could parse from reference null, // SupplierID taxInfo); }).ToList(); transactions.Add(new SaftTransaction( group.Key, period, transactionDate.ToString("yyyy-MM-dd"), firstEntry.Description ?? "Postering", transactionDate.ToString("yyyy-MM-ddTHH:mm:ss"), transactionDate.ToString("yyyy-MM-dd"), lines)); } // Single journal for general ledger if (transactions.Any()) { journals.Add(new SaftJournal( "GL", "Hovedbog", "GL", transactions)); } return new SaftGeneralLedgerEntries( ledgerEntries.Count, totalDebit, totalCredit, journals); } /// /// Maps internal account types to SAF-T standard account classifications. /// Note: The "financial" type is ambiguous in SAF-T mapping. Financial accounts /// can represent either income (e.g., interest income, account 8000-8499) or /// expense (e.g., interest expense, account 8500-8999). Without the account /// number or balance direction, we cannot determine the correct mapping. /// A future improvement should inspect the account number range or actual /// balance direction to choose between "Income" and "Expense". /// private static string MapAccountType(string accountType) { return accountType.ToLowerInvariant() switch { "asset" => "Asset", "liability" => "Liability", "equity" => "Equity", "revenue" => "Income", "cogs" => "Expense", "expense" => "Expense", "personnel" => "Expense", // Financial accounts are ambiguous: could be income (8000-8499) or expense (8500-8999). // Defaulting to "Expense" is safer since most financial items are costs (interest, fees). // TODO: Determine mapping based on account number range or balance direction. "financial" => "Expense", "extraordinary" => "Expense", _ => "Asset" }; } private static string GenerateFileName(CompanyReadModelDto company, FiscalYearReadModelDto fiscalYear) { // SAF-T DK naming convention: ___SAF-T.xml var cvr = company.Cvr ?? "00000000"; var startDate = fiscalYear.StartDate.ToString("yyyyMMdd"); var endDate = fiscalYear.EndDate.ToString("yyyyMMdd"); return $"{cvr}_{startDate}_{endDate}_SAF-T.xml"; } private static Guid? ParseAccountGuid(string accountId) { if (accountId.StartsWith("account-", StringComparison.Ordinal)) { var guidString = accountId["account-".Length..]; if (Guid.TryParse(guidString, out var guid)) return guid; } else if (Guid.TryParse(accountId, out var guid)) { return guid; } return null; } /// /// Extracts VAT/tax information from a ledger entry description. /// VAT lines generated by the system contain patterns like "Moms U25 (25%)" or "Moms I25 (25%)". /// private static SaftTaxInformation? ExtractTaxInformation(string? description, decimal amount) { if (string.IsNullOrEmpty(description)) return null; // Check for known VAT codes in the description foreach (var vatCodeInfo in VatCodes.All) { if (vatCodeInfo.Code == VatCodes.INGEN) continue; if (description.Contains(vatCodeInfo.Code, StringComparison.OrdinalIgnoreCase)) { var rate = VatCodes.GetRate(vatCodeInfo.Code); var taxBase = rate > 0 ? Math.Round(amount / rate, 2, MidpointRounding.AwayFromZero) : 0m; return new SaftTaxInformation( vatCodeInfo.Code, rate * 100, // TaxPercentage as whole number (25 not 0.25) taxBase, amount); } } return null; } /// /// Validates a Danish CVR number. /// A valid CVR is exactly 8 digits. /// private static bool IsValidCvr(string cvr) { if (string.IsNullOrWhiteSpace(cvr)) return false; // Remove any spaces or dashes var cleanCvr = cvr.Replace(" ", "").Replace("-", ""); // Must be exactly 8 digits return cleanCvr.Length == 8 && cleanCvr.All(char.IsDigit); } }