books/backend/Books.Api/Reporting/VatReportService.cs

225 lines
9.3 KiB
C#
Raw Normal View History

using Books.Api.EventFlow.Repositories;
using Ledger.Core.Models;
using Ledger.Core.Services;
namespace Books.Api.Reporting;
/// <summary>
/// Service for generating VAT (moms) reports from ledger data.
/// Aggregates VAT account balances for SKAT compliance.
/// </summary>
public class VatReportService(
IAccountRepository accountRepository,
ILedgerService ledgerService,
ILogger<VatReportService> logger) : IVatReportService
{
// Standard Danish VAT account numbers
Full product audit: fix security, compliance, UX, and wire broken features Security (Phase 1): - Add authentication middleware on /graphql endpoint - Filter company queries by user access (prevent IDOR) - Add role-based authorization on mutations (owner/accountant) - Reduce API key cache TTL from 24h to 5 minutes - Hide exception details in production GraphQL errors - Fix RBAC in frontend companyStore (was hardcoded) Wiring broken features (Phase 2): - Wire Kassekladde submit/void/copy to GraphQL mutations - Wire Kontooversigt account creation to createAccount mutation - Wire Settings save to updateCompany mutation - Wire CreateFiscalYearModal and CloseFiscalYearWizard to mutations - Replace Momsindberetning mock data with real useVatReport query - Remove Dashboard hardcoded percentages and fake VAT deadline - Fix Kreditnotaer invoice selector to use real data - Fix mutation retry from 1 to 0 (prevent duplicate operations) Accounting compliance (Phase 3): - Add balanced entry validation (debit==credit) in JournalEntryDraftAggregate - Add fiscal year boundary enforcement (status, date range checks) - Add PostedAt timestamp to posted events (Bogføringsloven §7) - Add account number uniqueness check within company - Add fiscal year overlap and gap checks - Add sequential invoice auto-numbering - Fix InvoiceLine VAT rate to use canonical VatCodes - Fix SAF-T account type mapping (financial → Expense) - Add DraftLine validation (cannot have both debit and credit > 0) UX improvements (Phase 4): - Fix Danish character encoding across 15+ files (ø, æ, å) - Deploy DemoDataDisclaimer on pages with mock/incomplete data - Adopt PageHeader component universally across all pages - Standardize active/inactive filtering to Switch pattern - Fix dead buttons in Header (Help, Notifications) - Remove hardcoded mock data from Settings - Fix Sidebar controlled state and Kontooversigt navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:35:26 +01:00
// 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<VatReportDto> 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<Guid>();
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;
Full product audit: fix security, compliance, UX, and wire broken features Security (Phase 1): - Add authentication middleware on /graphql endpoint - Filter company queries by user access (prevent IDOR) - Add role-based authorization on mutations (owner/accountant) - Reduce API key cache TTL from 24h to 5 minutes - Hide exception details in production GraphQL errors - Fix RBAC in frontend companyStore (was hardcoded) Wiring broken features (Phase 2): - Wire Kassekladde submit/void/copy to GraphQL mutations - Wire Kontooversigt account creation to createAccount mutation - Wire Settings save to updateCompany mutation - Wire CreateFiscalYearModal and CloseFiscalYearWizard to mutations - Replace Momsindberetning mock data with real useVatReport query - Remove Dashboard hardcoded percentages and fake VAT deadline - Fix Kreditnotaer invoice selector to use real data - Fix mutation retry from 1 to 0 (prevent duplicate operations) Accounting compliance (Phase 3): - Add balanced entry validation (debit==credit) in JournalEntryDraftAggregate - Add fiscal year boundary enforcement (status, date range checks) - Add PostedAt timestamp to posted events (Bogføringsloven §7) - Add account number uniqueness check within company - Add fiscal year overlap and gap checks - Add sequential invoice auto-numbering - Fix InvoiceLine VAT rate to use canonical VatCodes - Fix SAF-T account type mapping (financial → Expense) - Add DraftLine validation (cannot have both debit and credit > 0) UX improvements (Phase 4): - Fix Danish character encoding across 15+ files (ø, æ, å) - Deploy DemoDataDisclaimer on pages with mock/incomplete data - Adopt PageHeader component universally across all pages - Standardize active/inactive filtering to Switch pattern - Fix dead buttons in Header (Help, Notifications) - Remove hardcoded mock data from Settings - Fix Sidebar controlled state and Kontooversigt navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:35:26 +01:00
// 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<Guid>();
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);
}
}
Full product audit: fix security, compliance, UX, and wire broken features Security (Phase 1): - Add authentication middleware on /graphql endpoint - Filter company queries by user access (prevent IDOR) - Add role-based authorization on mutations (owner/accountant) - Reduce API key cache TTL from 24h to 5 minutes - Hide exception details in production GraphQL errors - Fix RBAC in frontend companyStore (was hardcoded) Wiring broken features (Phase 2): - Wire Kassekladde submit/void/copy to GraphQL mutations - Wire Kontooversigt account creation to createAccount mutation - Wire Settings save to updateCompany mutation - Wire CreateFiscalYearModal and CloseFiscalYearWizard to mutations - Replace Momsindberetning mock data with real useVatReport query - Remove Dashboard hardcoded percentages and fake VAT deadline - Fix Kreditnotaer invoice selector to use real data - Fix mutation retry from 1 to 0 (prevent duplicate operations) Accounting compliance (Phase 3): - Add balanced entry validation (debit==credit) in JournalEntryDraftAggregate - Add fiscal year boundary enforcement (status, date range checks) - Add PostedAt timestamp to posted events (Bogføringsloven §7) - Add account number uniqueness check within company - Add fiscal year overlap and gap checks - Add sequential invoice auto-numbering - Fix InvoiceLine VAT rate to use canonical VatCodes - Fix SAF-T account type mapping (financial → Expense) - Add DraftLine validation (cannot have both debit and credit > 0) UX improvements (Phase 4): - Fix Danish character encoding across 15+ files (ø, æ, å) - Deploy DemoDataDisclaimer on pages with mock/incomplete data - Adopt PageHeader component universally across all pages - Standardize active/inactive filtering to Switch pattern - Fix dead buttons in Header (Help, Notifications) - Remove hardcoded mock data from Settings - Fix Sidebar controlled state and Kontooversigt navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:35:26 +01:00
// 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);
}
Full product audit: fix security, compliance, UX, and wire broken features Security (Phase 1): - Add authentication middleware on /graphql endpoint - Filter company queries by user access (prevent IDOR) - Add role-based authorization on mutations (owner/accountant) - Reduce API key cache TTL from 24h to 5 minutes - Hide exception details in production GraphQL errors - Fix RBAC in frontend companyStore (was hardcoded) Wiring broken features (Phase 2): - Wire Kassekladde submit/void/copy to GraphQL mutations - Wire Kontooversigt account creation to createAccount mutation - Wire Settings save to updateCompany mutation - Wire CreateFiscalYearModal and CloseFiscalYearWizard to mutations - Replace Momsindberetning mock data with real useVatReport query - Remove Dashboard hardcoded percentages and fake VAT deadline - Fix Kreditnotaer invoice selector to use real data - Fix mutation retry from 1 to 0 (prevent duplicate operations) Accounting compliance (Phase 3): - Add balanced entry validation (debit==credit) in JournalEntryDraftAggregate - Add fiscal year boundary enforcement (status, date range checks) - Add PostedAt timestamp to posted events (Bogføringsloven §7) - Add account number uniqueness check within company - Add fiscal year overlap and gap checks - Add sequential invoice auto-numbering - Fix InvoiceLine VAT rate to use canonical VatCodes - Fix SAF-T account type mapping (financial → Expense) - Add DraftLine validation (cannot have both debit and credit > 0) UX improvements (Phase 4): - Fix Danish character encoding across 15+ files (ø, æ, å) - Deploy DemoDataDisclaimer on pages with mock/incomplete data - Adopt PageHeader component universally across all pages - Standardize active/inactive filtering to Switch pattern - Fix dead buttons in Header (Help, Notifications) - Remove hardcoded mock data from Settings - Fix Sidebar controlled state and Kontooversigt navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:35:26 +01:00
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;
}
}