using Books.Api.EventFlow.Repositories; using Ledger.Core.Models; using Ledger.Core.Services; namespace Books.Api.Reporting; /// /// Service for generating VAT (moms) reports from ledger data. /// Aggregates VAT account balances for SKAT compliance. /// public class VatReportService( IAccountRepository accountRepository, ILedgerService ledgerService, ILogger logger) : IVatReportService { // Standard Danish VAT account numbers // TODO: These should ideally come from company-level configuration, // as different chart-of-accounts templates may use different numbers. private const string InputVatAccountNumber = "5610"; // Købsmoms (indgående moms) private const string OutputVatAccountNumber = "5611"; // Salgsmoms (udgående moms) private const string EuAcquisitionVatAccountNumber = "5620"; // EU erhvervelsesmoms public async Task GenerateReportAsync( string companyId, DateOnly periodStart, DateOnly periodEnd, CancellationToken ct = default) { // Validate period if (periodEnd < periodStart) { throw new ArgumentException("Slutdato skal være efter startdato", nameof(periodEnd)); } var daysDiff = periodEnd.DayNumber - periodStart.DayNumber; if (daysDiff > 366) { throw new ArgumentException("Perioden må ikke overstige ét år (366 dage)", nameof(periodEnd)); } logger.LogInformation( "Generating VAT report for company {CompanyId} from {PeriodStart} to {PeriodEnd}", companyId, periodStart, periodEnd); // Look up VAT accounts var inputVatAccount = await accountRepository.GetByCompanyAndNumberAsync( companyId, InputVatAccountNumber, ct); var outputVatAccount = await accountRepository.GetByCompanyAndNumberAsync( companyId, OutputVatAccountNumber, ct); var euAcquisitionVatAccount = await accountRepository.GetByCompanyAndNumberAsync( companyId, EuAcquisitionVatAccountNumber, ct); var report = new VatReportDto { PeriodStart = periodStart, PeriodEnd = periodEnd }; // If no VAT accounts exist, return empty report if (inputVatAccount == null && outputVatAccount == null && euAcquisitionVatAccount == null) { logger.LogWarning( "No VAT accounts found for company {CompanyId}. Returning empty report.", companyId); return report; } // Collect account IDs for ledger query var accountIds = new List(); if (inputVatAccount != null && TryParseAccountGuid(inputVatAccount.Id, out var inputGuid)) { accountIds.Add(inputGuid); } if (outputVatAccount != null && TryParseAccountGuid(outputVatAccount.Id, out var outputGuid)) { accountIds.Add(outputGuid); } if (euAcquisitionVatAccount != null && TryParseAccountGuid(euAcquisitionVatAccount.Id, out var euAcquisitionGuid)) { accountIds.Add(euAcquisitionGuid); } if (accountIds.Count == 0) { logger.LogWarning("No valid VAT account GUIDs found for company {CompanyId}", companyId); return report; } // Query ledger for aggregated balances in the period var query = new EntriesQuery { AccountIds = accountIds, From = new DateTimeOffset(periodStart.ToDateTime(TimeOnly.MinValue)), To = new DateTimeOffset(periodEnd.ToDateTime(TimeOnly.MaxValue)), Aggregate = true }; var result = await ledgerService.QueryEntriesAsync(query, ct); // Process aggregated balances if (result.Aggregates != null) { foreach (var balance in result.Aggregates) { if (inputVatAccount != null && TryParseAccountGuid(inputVatAccount.Id, out var checkInputGuid) && balance.AccountId == checkInputGuid) { // Input VAT (5610): Debits are VAT receivable/deductions // Box B = total input VAT deduction report.BoxB = balance.TotalDebits; report.TransactionCount += balance.EntryCount; logger.LogDebug( "Input VAT (5610): TotalDebits={Debits}, TotalCredits={Credits}, NetChange={Net}", balance.TotalDebits, balance.TotalCredits, balance.NetChange); } else if (outputVatAccount != null && TryParseAccountGuid(outputVatAccount.Id, out var checkOutputGuid) && balance.AccountId == checkOutputGuid) { // Output VAT (5611): Credits are VAT payable to SKAT // Box A = total output VAT (salgsmoms) report.BoxA = balance.TotalCredits; report.TransactionCount += balance.EntryCount; logger.LogDebug( "Output VAT (5611): TotalDebits={Debits}, TotalCredits={Credits}, NetChange={Net}", balance.TotalDebits, balance.TotalCredits, balance.NetChange); } else if (euAcquisitionVatAccount != null && TryParseAccountGuid(euAcquisitionVatAccount.Id, out var checkEuGuid) && balance.AccountId == checkEuGuid) { // EU Acquisition VAT (5620): Debits are reverse charge VAT // This covers both EU goods (IEUV) and EU/world services (IEUY/IVY) // Box C + Box D = total EU acquisition VAT debits // Without VAT code breakdown at ledger level, we report the total // in Box C (EU goods). When VAT code-level data becomes available, // split IEUV -> Box C and IEUY/IVY -> Box D. report.BoxC = balance.TotalDebits; report.TransactionCount += balance.EntryCount; // Basis3 = base amount for EU acquisition (reverse charge VAT / 0.25) if (balance.TotalDebits > 0) { report.Basis3 = Math.Round(balance.TotalDebits / 0.25m, 2); } logger.LogDebug( "EU Acquisition VAT (5620): TotalDebits={Debits}, TotalCredits={Credits}, NetChange={Net}", balance.TotalDebits, balance.TotalCredits, balance.NetChange); } } } // Calculate summary totals // Box A = Salgsmoms (domestic output VAT) // Box B = Købsmoms (input VAT - deductible) // Box C = EU-varekøb moms (reverse charge on EU goods/services) // Box D = Ydelseskøb moms (reverse charge on services from abroad) report.TotalOutputVat = report.BoxA + report.BoxC + report.BoxD; report.TotalInputVat = report.BoxB; report.NetVat = report.TotalOutputVat - report.TotalInputVat; // Basis1 (Felt 1): Net domestic turnover with VAT // Query revenue account totals (accounts 1000-1999) for actual turnover basis // instead of back-calculating from VAT which is inaccurate with mixed rates var revenueAccounts = await accountRepository.GetByCompanyIdAsync(companyId, ct); var revenueAccountIds = new List(); foreach (var acc in revenueAccounts) { if (int.TryParse(acc.AccountNumber, out var num) && num >= 1000 && num <= 1999 && TryParseAccountGuid(acc.Id, out var revGuid)) { revenueAccountIds.Add(revGuid); } } if (revenueAccountIds.Count > 0) { var revenueQuery = new EntriesQuery { AccountIds = revenueAccountIds, From = new DateTimeOffset(periodStart.ToDateTime(TimeOnly.MinValue)), To = new DateTimeOffset(periodEnd.ToDateTime(TimeOnly.MaxValue)), Aggregate = true }; var revenueResult = await ledgerService.QueryEntriesAsync(revenueQuery, ct); if (revenueResult.Aggregates != null) { // Revenue accounts have credits for income; sum the credits report.Basis1 = revenueResult.Aggregates.Sum(a => a.TotalCredits); } } // Fallback: if no revenue data found, back-calculate from output VAT if (report.Basis1 == 0 && report.BoxA > 0) { report.Basis1 = Math.Round(report.BoxA / 0.25m, 2); } logger.LogInformation( "VAT report generated for company {CompanyId}: OutputVAT={OutputVat}, InputVAT={InputVat}, NetVAT={NetVat}", companyId, report.TotalOutputVat, report.TotalInputVat, report.NetVat); return report; } private static bool TryParseAccountGuid(string accountId, out Guid guid) { // Account IDs are in format "account-{guid}" if (accountId.StartsWith("account-")) { return Guid.TryParse(accountId.Replace("account-", ""), out guid); } guid = Guid.Empty; return false; } }