diff --git a/backend/Books.Api/Banking/EnableBankingClient.cs b/backend/Books.Api/Banking/EnableBankingClient.cs index 29008d7..56e78b9 100644 --- a/backend/Books.Api/Banking/EnableBankingClient.cs +++ b/backend/Books.Api/Banking/EnableBankingClient.cs @@ -128,9 +128,9 @@ public class EnableBankingClient : IEnableBankingClient, IDisposable var response = await _httpClient.SendAsync(request, ct); response.EnsureSuccessStatusCode(); - // Log raw response for debugging + // Log raw response for debugging (sensitive - only at Debug level) var rawJson = await response.Content.ReadAsStringAsync(ct); - _logger.LogInformation("Raw session response: {RawJson}", rawJson); + _logger.LogDebug("Raw session response: {ResponseLength} chars", rawJson.Length); var result = System.Text.Json.JsonSerializer.Deserialize(rawJson, _jsonOptions); var sessionId = result!.SessionId; @@ -225,7 +225,7 @@ public class EnableBankingClient : IEnableBankingClient, IDisposable var response = await _httpClient.SendAsync(request, ct); var rawJson = await response.Content.ReadAsStringAsync(ct); - _logger.LogInformation("Transactions API response ({Status}): {RawJson}", response.StatusCode, rawJson); + _logger.LogDebug("Transactions API response ({Status}): {ResponseLength} chars", response.StatusCode, rawJson.Length); response.EnsureSuccessStatusCode(); diff --git a/backend/Books.Api/Commands/Invoices/InvoiceCommandHandlers.cs b/backend/Books.Api/Commands/Invoices/InvoiceCommandHandlers.cs index e3b09a4..67ac6fa 100644 --- a/backend/Books.Api/Commands/Invoices/InvoiceCommandHandlers.cs +++ b/backend/Books.Api/Commands/Invoices/InvoiceCommandHandlers.cs @@ -225,21 +225,32 @@ public class VoidInvoiceCommandHandler // CREDIT NOTE COMMAND HANDLERS // ===================================================== -public class CreateCreditNoteCommandHandler +/// +/// Command handler for creating credit notes. +/// Auto-assigns sequential credit note numbers (Momsloven §52 - sequential numbering required). +/// +public class CreateCreditNoteCommandHandler( + IInvoiceNumberService invoiceNumberService) : CommandHandler { - public override Task ExecuteAsync( + public override async Task ExecuteAsync( InvoiceAggregate aggregate, CreateCreditNoteCommand command, CancellationToken cancellationToken) { + // Auto-assign credit note number (Momsloven §52 requires unbroken sequential numbering) + var creditNoteNumber = await invoiceNumberService.GetNextCreditNoteNumberAsync( + command.CompanyId, + command.CreditNoteDate.Year, + cancellationToken); + aggregate.CreateCreditNote( command.CompanyId, command.FiscalYearId, command.CustomerId, command.CustomerName, command.CustomerNumber, - command.CreditNoteNumber, + creditNoteNumber, command.CreditNoteDate, command.Currency, command.VatCode, @@ -249,8 +260,6 @@ public class CreateCreditNoteCommandHandler command.OriginalInvoiceId, command.OriginalInvoiceNumber, command.CreditReason); - - return Task.CompletedTask; } } diff --git a/backend/Books.Api/Controllers/AttachmentController.cs b/backend/Books.Api/Controllers/AttachmentController.cs index 5f51d03..f791299 100644 --- a/backend/Books.Api/Controllers/AttachmentController.cs +++ b/backend/Books.Api/Controllers/AttachmentController.cs @@ -185,13 +185,15 @@ public class AttachmentController( [HttpGet("{*storagePath}")] public async Task Download(string storagePath, CancellationToken cancellationToken) { - // Validate path to prevent directory traversal attacks - if (string.IsNullOrWhiteSpace(storagePath) || - storagePath.Contains("..") || - storagePath.Contains("~") || - Path.IsPathRooted(storagePath) || - storagePath.StartsWith("/") || - storagePath.StartsWith("\\")) + // Validate path to prevent directory traversal attacks using canonical path resolution + if (string.IsNullOrWhiteSpace(storagePath)) + { + return BadRequest(new { error = "INVALID_PATH", message = "Invalid storage path" }); + } + + var basePath = fileStorage.GetBasePath(); + var fullPath = Path.GetFullPath(Path.Combine(basePath, storagePath)); + if (!fullPath.StartsWith(Path.GetFullPath(basePath), StringComparison.Ordinal)) { logger.LogWarning("Attempted path traversal attack with path: {StoragePath}", storagePath); return BadRequest(new { error = "INVALID_PATH", message = "Invalid storage path" }); diff --git a/backend/Books.Api/Controllers/BankingController.cs b/backend/Books.Api/Controllers/BankingController.cs index bd95f10..1d6ac25 100644 --- a/backend/Books.Api/Controllers/BankingController.cs +++ b/backend/Books.Api/Controllers/BankingController.cs @@ -1,6 +1,11 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Books.Api.Authorization; using Books.Api.Banking; using Books.Api.Commands.BankConnections; using Books.Api.Domain.BankConnections; +using Books.Api.EventFlow.Repositories; using EventFlow; using EventFlow.Aggregates.ExecutionResults; using Microsoft.AspNetCore.Authorization; @@ -17,17 +22,23 @@ public class BankingController : ControllerBase private readonly IEnableBankingClient _bankingClient; private readonly ILogger _logger; private readonly IConfiguration _configuration; + private readonly IBankConnectionRepository _bankConnectionRepository; + private readonly ICompanyAccessService _companyAccess; public BankingController( ICommandBus commandBus, IEnableBankingClient bankingClient, ILogger logger, - IConfiguration configuration) + IConfiguration configuration, + IBankConnectionRepository bankConnectionRepository, + ICompanyAccessService companyAccess) { _commandBus = commandBus; _bankingClient = bankingClient; _logger = logger; _configuration = configuration; + _bankConnectionRepository = bankConnectionRepository; + _companyAccess = companyAccess; } /// @@ -60,12 +71,30 @@ public class BankingController : ControllerBase try { - // TODO: Add proper CSRF/state validation. Currently the state parameter - // is used as the connection ID, but it should also include a CSRF token - // that is validated against the user session to prevent cross-site request - // forgery attacks on the OAuth callback. - // State contains the connection ID (set during StartBankConnection) - var connectionId = state; + // Validate HMAC-signed state token to prevent CSRF attacks + var connectionId = ValidateStateToken(state); + if (connectionId == null) + { + _logger.LogWarning("Invalid or tampered state token in bank callback"); + return Redirect($"{redirectUrl}&error=invalid_state"); + } + + // Verify the user has access to the company that owns this bank connection + var bankConnection = await _bankConnectionRepository.GetByIdAsync(connectionId, ct); + if (bankConnection == null) + { + _logger.LogWarning("Bank connection {ConnectionId} not found", connectionId); + return Redirect($"{redirectUrl}&error=connection_not_found"); + } + + var canWrite = await _companyAccess.CanWriteAsync(bankConnection.CompanyId, ct); + if (!canWrite) + { + _logger.LogWarning( + "User does not have write access to company {CompanyId} for bank connection {ConnectionId}", + bankConnection.CompanyId, connectionId); + return Redirect($"{redirectUrl}&error=access_denied"); + } // Get PSU headers from HttpContext (required by Enable Banking API) var psuIpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); @@ -108,4 +137,52 @@ public class BankingController : ControllerBase return Redirect($"{redirectUrl}&error=internal_error"); } } + + /// + /// Generates an HMAC-signed state token containing the connection ID. + /// Used during StartBankConnection to create a CSRF-safe state parameter. + /// + public static string GenerateStateToken(string connectionId, string secret) + { + var signature = ComputeHmac(connectionId, secret); + return $"{connectionId}.{signature}"; + } + + /// + /// Validates an HMAC-signed state token and extracts the connection ID. + /// Returns null if the token is invalid or tampered with. + /// + private string? ValidateStateToken(string state) + { + var secret = _configuration["Banking:StateSecret"] ?? _configuration["Jwt:Key"] ?? ""; + + // Support legacy format (plain connection ID without signature) during transition + if (!state.Contains('.')) + { + // Legacy format: treat as plain connection ID but log a warning + _logger.LogWarning("Bank callback received legacy state format without HMAC signature"); + return state; + } + + var dotIndex = state.LastIndexOf('.'); + var connectionId = state[..dotIndex]; + var providedSignature = state[(dotIndex + 1)..]; + + var expectedSignature = ComputeHmac(connectionId, secret); + if (!CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(expectedSignature), + Encoding.UTF8.GetBytes(providedSignature))) + { + return null; + } + + return connectionId; + } + + private static string ComputeHmac(string data, string secret) + { + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(data)); + return Convert.ToBase64String(hash); + } } diff --git a/backend/Books.Api/Domain/Companies/CompanyAggregate.cs b/backend/Books.Api/Domain/Companies/CompanyAggregate.cs index b31edbb..f8375f5 100644 --- a/backend/Books.Api/Domain/Companies/CompanyAggregate.cs +++ b/backend/Books.Api/Domain/Companies/CompanyAggregate.cs @@ -3,7 +3,10 @@ using EventFlow.Aggregates; namespace Books.Api.Domain.Companies; -public class CompanyAggregate(CompanyId id) : AggregateRoot(id) +public class CompanyAggregate(CompanyId id) : AggregateRoot(id), + IEmit, + IEmit, + IEmit { private bool _isCreated; @@ -11,6 +14,8 @@ public class CompanyAggregate(CompanyId id) : AggregateRoot 100) + throw new DomainException("INVALID_DISCOUNT", "Discount must be between 0% and 100%", "Rabat skal være mellem 0% og 100%"); + var lineNumber = _lines.Count > 0 ? _lines.Max(l => l.LineNumber) + 1 : 1; Emit(new InvoiceLineAddedEvent( @@ -231,6 +234,9 @@ public class InvoiceAggregate(InvoiceId id) : AggregateRoot l.LineNumber == lineNumber)) throw new DomainException("LINE_NOT_FOUND", $"Line {lineNumber} not found", $"Linje {lineNumber} findes ikke"); + if (discountPercent < 0 || discountPercent > 100) + throw new DomainException("INVALID_DISCOUNT", "Discount must be between 0% and 100%", "Rabat skal være mellem 0% og 100%"); + Emit(new InvoiceLineUpdatedEvent( lineNumber, description.Trim(), diff --git a/backend/Books.Api/Domain/JournalEntryDrafts/VatCalculationService.cs b/backend/Books.Api/Domain/JournalEntryDrafts/VatCalculationService.cs index a8b26f3..09fd0c0 100644 --- a/backend/Books.Api/Domain/JournalEntryDrafts/VatCalculationService.cs +++ b/backend/Books.Api/Domain/JournalEntryDrafts/VatCalculationService.cs @@ -147,34 +147,59 @@ public class VatCalculationService : IVatCalculationService vatAmount = Math.Round(amount * rate, 2, MidpointRounding.AwayFromZero); } + // Apply deductibility percentage (Momsloven §42 stk. 2 for REP) + var deductiblePercent = VatCodes.GetDeductiblePercent(line.VatCode); + var deductibleVatAmount = Math.Round(vatAmount * deductiblePercent, 2, MidpointRounding.AwayFromZero); + // Determine if this is input or output VAT var isInputVat = VatCodes.IsInputVat(line.VatCode); var vatAccountId = isInputVat ? inputVatAccountId : outputVatAccountId; + var isReverseCharge = line.VatCode is VatCodes.IEUV or VatCodes.IEUY or VatCodes.IVY; - // Create VAT posting line - // For sales (output VAT): we credit the VAT account - // For purchases (input VAT): we debit the VAT account - var vatLine = new VatPostingLine + if (isReverseCharge) { - AccountId = vatAccountId, - DebitAmount = isInputVat && isDebit ? vatAmount : (isInputVat ? 0 : 0), - CreditAmount = !isInputVat && !isDebit ? vatAmount : (!isInputVat && isDebit ? vatAmount : 0), - Description = $"Moms {line.VatCode} ({rate * 100:0}%)", - VatCode = line.VatCode, - SourceLineNumber = line.LineNumber - }; + // Reverse charge requires TWO VAT lines: + // 1. Debit EU acquisition VAT account (5620) - VAT payable + // 2. Credit input VAT account (5610) - VAT deductible + // These offset each other, resulting in no net VAT effect + const string euAcquisitionVatAccount = "5620"; - // Correct the debit/credit logic: - // - For SALES (U25): revenue is credit, VAT should ALSO be credit (liability to SKAT) - // - For PURCHASES (I25): expense is debit, VAT should ALSO be debit (asset/receivable from SKAT) - // The key insight: VAT follows the same direction as the base transaction - vatLine = vatLine with + var reverseChargeDebitLine = new VatPostingLine + { + AccountId = euAcquisitionVatAccount, + DebitAmount = isDebit ? vatAmount : 0, + CreditAmount = !isDebit ? vatAmount : 0, + Description = $"Moms {line.VatCode} ({rate * 100:0}%) - EU erhvervelse", + VatCode = line.VatCode, + SourceLineNumber = line.LineNumber + }; + vatLines.Add(reverseChargeDebitLine); + + var reverseChargeCreditLine = new VatPostingLine + { + AccountId = inputVatAccountId, + DebitAmount = !isDebit ? vatAmount : 0, + CreditAmount = isDebit ? vatAmount : 0, + Description = $"Moms {line.VatCode} ({rate * 100:0}%) - indgående moms", + VatCode = line.VatCode, + SourceLineNumber = line.LineNumber + }; + vatLines.Add(reverseChargeCreditLine); + } + else { - DebitAmount = isDebit ? vatAmount : 0, - CreditAmount = !isDebit ? vatAmount : 0 - }; - - vatLines.Add(vatLine); + // Standard VAT posting line (using deductible amount for REP) + var vatLine = new VatPostingLine + { + AccountId = vatAccountId, + DebitAmount = isDebit ? deductibleVatAmount : 0, + CreditAmount = !isDebit ? deductibleVatAmount : 0, + Description = $"Moms {line.VatCode} ({rate * 100:0}%)", + VatCode = line.VatCode, + SourceLineNumber = line.LineNumber + }; + vatLines.Add(vatLine); + } // Accumulate VAT summary if (!vatByCode.TryGetValue(line.VatCode, out var summary)) diff --git a/backend/Books.Api/Domain/Orders/OrderAggregate.cs b/backend/Books.Api/Domain/Orders/OrderAggregate.cs index b7d04f7..420528d 100644 --- a/backend/Books.Api/Domain/Orders/OrderAggregate.cs +++ b/backend/Books.Api/Domain/Orders/OrderAggregate.cs @@ -229,6 +229,9 @@ public class OrderAggregate(OrderId id) : AggregateRoot if (unitPrice < 0) throw new DomainException("INVALID_UNIT_PRICE", "Unit price cannot be negative", "Stykpris kan ikke være negativ"); + if (discountPercent < 0 || discountPercent > 100) + throw new DomainException("INVALID_DISCOUNT", "Discount must be between 0% and 100%", "Rabat skal være mellem 0% og 100%"); + var lineNumber = _lines.Count > 0 ? _lines.Max(l => l.LineNumber) + 1 : 1; Emit(new OrderLineAddedEvent( @@ -267,6 +270,9 @@ public class OrderAggregate(OrderId id) : AggregateRoot if (line.IsInvoiced) throw new DomainException("LINE_ALREADY_INVOICED", $"Line {lineNumber} has already been invoiced", $"Linje {lineNumber} er allerede faktureret"); + if (discountPercent < 0 || discountPercent > 100) + throw new DomainException("INVALID_DISCOUNT", "Discount must be between 0% and 100%", "Rabat skal være mellem 0% og 100%"); + Emit(new OrderLineUpdatedEvent( lineNumber, description.Trim(), diff --git a/backend/Books.Api/Domain/Products/ProductAggregate.cs b/backend/Books.Api/Domain/Products/ProductAggregate.cs index 917cc09..e4d7c23 100644 --- a/backend/Books.Api/Domain/Products/ProductAggregate.cs +++ b/backend/Books.Api/Domain/Products/ProductAggregate.cs @@ -130,5 +130,10 @@ public class ProductAggregate(ProductId id) : AggregateRoot 0.25m, IEUV => 0.25m, // Reverse charge - calculated but offset IEUY => 0.25m, // Reverse charge - calculated but offset + IVY => 0.25m, // Reverse charge for imported services (Momsloven §46) REP => 0.25m, // 25% rate, but only 25% deductible _ => 0m }; @@ -43,6 +44,7 @@ public static class VatCodes I25 => 1.0m, // 100% deductible IEUV => 1.0m, // 100% deductible (reverse charge) IEUY => 1.0m, // 100% deductible (reverse charge) + IVY => 1.0m, // 100% deductible (reverse charge, Momsloven §46) REP => 0.25m, // Only 25% deductible _ => 0m }; @@ -80,7 +82,7 @@ public static class VatCodes new(IEUV, "EU-køb varer", "EU Purchase Goods", 0.25m), new(IEUY, "EU-køb ydelser", "EU Purchase Services", 0.25m), new(IVV, "Import varer", "Import Goods", 0m), - new(IVY, "Import ydelser", "Import Services", 0m), + new(IVY, "Import ydelser", "Import Services", 0.25m), new(REP, "Repræsentation", "Entertainment", 0.25m), ]; } diff --git a/backend/Books.Api/Infrastructure/FileStorage/IFileStorageService.cs b/backend/Books.Api/Infrastructure/FileStorage/IFileStorageService.cs index cbc16de..fd3c384 100644 --- a/backend/Books.Api/Infrastructure/FileStorage/IFileStorageService.cs +++ b/backend/Books.Api/Infrastructure/FileStorage/IFileStorageService.cs @@ -45,6 +45,11 @@ public interface IFileStorageService /// URL expiration time /// Download URL string GetDownloadUrl(string storagePath, TimeSpan? expiresIn = null); + + /// + /// Gets the base storage path for canonical path validation. + /// + string GetBasePath(); } public record StorageResult diff --git a/backend/Books.Api/Infrastructure/FileStorage/LocalFileStorageService.cs b/backend/Books.Api/Infrastructure/FileStorage/LocalFileStorageService.cs index b28da9c..0d56751 100644 --- a/backend/Books.Api/Infrastructure/FileStorage/LocalFileStorageService.cs +++ b/backend/Books.Api/Infrastructure/FileStorage/LocalFileStorageService.cs @@ -95,11 +95,15 @@ public class LocalFileStorageService : IFileStorageService return Task.CompletedTask; } + public string GetBasePath() => _basePath; + public string GetDownloadUrl(string storagePath, TimeSpan? expiresIn = null) { // For local storage, return API endpoint URL // The actual download is handled by a controller - return $"{_baseUrl}/{Uri.EscapeDataString(storagePath)}"; + // Encode each path segment individually to avoid double-encoding path separators + var encodedPath = string.Join("/", storagePath.Split('/').Select(Uri.EscapeDataString)); + return $"{_baseUrl}/{encodedPath}"; } private static string SanitizeFileName(string fileName) diff --git a/backend/Books.Api/Invoicing/Services/PaymentMatchingService.cs b/backend/Books.Api/Invoicing/Services/PaymentMatchingService.cs index 184a327..9f910c1 100644 --- a/backend/Books.Api/Invoicing/Services/PaymentMatchingService.cs +++ b/backend/Books.Api/Invoicing/Services/PaymentMatchingService.cs @@ -512,7 +512,6 @@ public partial class PaymentMatchingService( .ToUpperInvariant() .Replace("APS", "") .Replace("A/S", "") - .Replace("ApS", "") .Replace("IVS", "") .Replace("K/S", "") .Trim(); diff --git a/backend/Books.Api/Reporting/VatReportService.cs b/backend/Books.Api/Reporting/VatReportService.cs index 62e0390..f1fbd50 100644 --- a/backend/Books.Api/Reporting/VatReportService.cs +++ b/backend/Books.Api/Reporting/VatReportService.cs @@ -16,8 +16,9 @@ public class VatReportService( // 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 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, @@ -46,6 +47,8 @@ public class VatReportService( companyId, InputVatAccountNumber, ct); var outputVatAccount = await accountRepository.GetByCompanyAndNumberAsync( companyId, OutputVatAccountNumber, ct); + var euAcquisitionVatAccount = await accountRepository.GetByCompanyAndNumberAsync( + companyId, EuAcquisitionVatAccountNumber, ct); var report = new VatReportDto { @@ -53,8 +56,8 @@ public class VatReportService( PeriodEnd = periodEnd }; - // If neither VAT account exists, return empty report - if (inputVatAccount == null && outputVatAccount == null) + // 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.", @@ -75,6 +78,11 @@ public class VatReportService( 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); @@ -123,40 +131,78 @@ public class VatReportService( "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 (not yet supported - requires VAT code breakdown) - // Box D = Ydelseskøb moms (not yet supported - requires VAT code breakdown) + // 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 - // TODO: Query actual net turnover from transactions with output VAT codes (U25) - // instead of back-calculating from VAT amount, which is inaccurate when - // mixed VAT rates or partial deductions are involved. - // Ideally: query revenue account balances filtered by VAT code U25. - // For now, back-calculate from output VAT assuming standard 25% rate - if (report.BoxA > 0) + // 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); } - // TODO: Box C (EU-varekøb moms) - Requires VAT code breakdown from transactions. - // Query transactions with VAT code IEUV to compute reverse-charge VAT on EU goods. - // report.BoxC = sum of VAT calculated on IEUV transactions. - // report.Basis3 = net purchase amount for IEUV transactions. - - // TODO: Box D (Ydelseskøb moms) - Requires VAT code breakdown from transactions. - // Query transactions with VAT codes IEUY, IVV, IVY to compute reverse-charge VAT - // on services purchased from abroad. - // report.BoxD = sum of VAT calculated on IEUY/IVV/IVY transactions. - // report.Basis4 = net purchase amount for IEUY/IVV/IVY transactions. - logger.LogInformation( "VAT report generated for company {CompanyId}: OutputVAT={OutputVat}, InputVAT={InputVat}, NetVAT={NetVat}", companyId, report.TotalOutputVat, report.TotalInputVat, report.NetVat); diff --git a/backend/Books.Api/Saft/Services/SaftExportService.cs b/backend/Books.Api/Saft/Services/SaftExportService.cs index df65a15..55c41bb 100644 --- a/backend/Books.Api/Saft/Services/SaftExportService.cs +++ b/backend/Books.Api/Saft/Services/SaftExportService.cs @@ -212,14 +212,16 @@ public class SaftExportService( accountBalances.TryGetValue(guid ?? Guid.Empty, out var periodBalance); openingBalances.TryGetValue(guid ?? Guid.Empty, out var openingBalance); - // Opening balance = net balance from all transactions before period start - // Net balance is calculated as: Debits - Credits for Asset/Expense, Credits - Debits for Liability/Equity/Income - var openingDebit = openingBalance.TotalDebits; - var openingCredit = openingBalance.TotalCredits; + // 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 balance + Period movements - var closingDebit = openingDebit + periodBalance.TotalDebits; - var closingCredit = openingCredit + periodBalance.TotalCredits; + // 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, diff --git a/backend/Books.Api/Saft/Services/SaftXmlBuilder.cs b/backend/Books.Api/Saft/Services/SaftXmlBuilder.cs index fa6b083..db840ba 100644 --- a/backend/Books.Api/Saft/Services/SaftXmlBuilder.cs +++ b/backend/Books.Api/Saft/Services/SaftXmlBuilder.cs @@ -10,7 +10,7 @@ namespace Books.Api.Saft.Services; /// public class SaftXmlBuilder { - private const string SaftNamespace = "urn:StandardAuditFile-Taxation-Financial:DK"; + private const string SaftNamespace = "urn:OECD:StandardAuditFile-Taxation/2.00"; /// /// Builds a SAF-T XML document from the provided data. @@ -166,7 +166,7 @@ public class SaftXmlBuilder 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", 0.00m); + WriteTaxCodeDetails(writer, "IVY", "Import ydelser fra verden (reverse charge)", 25.00m); // Special codes WriteTaxCodeDetails(writer, "REP", "Repræsentation (25% fradrag)", 25.00m); @@ -347,11 +347,19 @@ public class SaftXmlBuilder if (!string.IsNullOrEmpty(line.Description)) writer.WriteElementString("Description", line.Description); - if (line.DebitAmount.HasValue && line.DebitAmount.Value != 0) - writer.WriteElementString("DebitAmount", FormatDecimal(line.DebitAmount.Value)); + // 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 (line.CreditAmount.HasValue && line.CreditAmount.Value != 0) - writer.WriteElementString("CreditAmount", FormatDecimal(line.CreditAmount.Value)); + 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); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5930d99..3b5fb03 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -51,8 +51,8 @@ export async function fetchGraphQL(query, variables, headers); return data; } catch (error) { - // Log error for debugging - console.error('GraphQL Error:', error); + // Log error for debugging (dev only) + if (import.meta.env.DEV) console.error('GraphQL Error:', error); // Re-throw with more context if (error instanceof Error) { diff --git a/frontend/src/components/settings/BankConnectionsTab.tsx b/frontend/src/components/settings/BankConnectionsTab.tsx index 5de2d18..6f1659f 100644 --- a/frontend/src/components/settings/BankConnectionsTab.tsx +++ b/frontend/src/components/settings/BankConnectionsTab.tsx @@ -19,6 +19,7 @@ import { DatePicker, } from 'antd'; import { showSuccess, showError } from '@/lib/errorHandling'; +import { formatDate } from '@/lib/formatters'; import type { Dayjs } from 'dayjs'; import dayjs from 'dayjs'; import { @@ -64,13 +65,6 @@ function getStatusTag(status: BankConnection['status'], isActive: boolean) { return {status}; } -function formatDate(dateString: string) { - return new Date(dateString).toLocaleDateString('da-DK', { - year: 'numeric', - month: 'short', - day: 'numeric', - }); -} export default function BankConnectionsTab({ companyId }: BankConnectionsTabProps) { const [searchParams, setSearchParams] = useSearchParams(); diff --git a/frontend/src/components/shared/AmountText.tsx b/frontend/src/components/shared/AmountText.tsx index 2aa746a..d622836 100644 --- a/frontend/src/components/shared/AmountText.tsx +++ b/frontend/src/components/shared/AmountText.tsx @@ -1,6 +1,6 @@ import { Typography, Tooltip } from 'antd'; import { ArrowUpOutlined, ArrowDownOutlined, MinusOutlined } from '@ant-design/icons'; -import { formatCurrency } from '@/lib/formatters'; +import { formatCurrency, formatNumber } from '@/lib/formatters'; import { accountingColors } from '@/styles/theme'; import { typography, getAmountColor } from '@/styles/designTokens'; @@ -77,7 +77,7 @@ export function AmountText({ }; const formatAmount = (): string => { - const formatted = formatCurrency(Math.abs(amount)); + const formatted = formatNumber(Math.abs(amount)); // Always show +/- prefix for non-zero amounts (accessibility: not color-only) // When showSign is explicitly true, same behavior; kept for API compatibility const alwaysSign = showSign; diff --git a/frontend/src/lib/accounting.ts b/frontend/src/lib/accounting.ts index 11f3e4d..47d3db5 100644 --- a/frontend/src/lib/accounting.ts +++ b/frontend/src/lib/accounting.ts @@ -138,11 +138,11 @@ export const VAT_RATES = { * Standard Danish VAT codes */ export const VAT_CODES = { - S25: { code: 'S25', name: 'Udgående moms 25%', rate: 0.25, type: 'output' }, - K25: { code: 'K25', name: 'Indgående moms 25%', rate: 0.25, type: 'input' }, + U25: { code: 'U25', name: 'Udgående moms 25%', rate: 0.25, type: 'output' }, + I25: { code: 'I25', name: 'Indgående moms 25%', rate: 0.25, type: 'input' }, E0: { code: 'E0', name: 'EU-varekøb 0%', rate: 0, type: 'eu' }, U0: { code: 'U0', name: 'Eksport 0%', rate: 0, type: 'export' }, - NONE: { code: 'NONE', name: 'Ingen moms', rate: 0, type: 'none' }, + INGEN: { code: 'INGEN', name: 'Ingen moms', rate: 0, type: 'none' }, } as const; /** @@ -298,11 +298,11 @@ export function generateSimpleDoubleEntry(input: SimpleBookingInput): GeneratedT // For reverse charge (EU purchases), also credit output VAT if (vatConfig.reverseCharge && vatAmount > 0) { - const outputVatAccount = vatCode === 'EU_VARE' ? VAT_ACCOUNTS.euVAT : VAT_ACCOUNTS.outputVAT; + const outputVatAccount = vatCode === 'IEUV' ? VAT_ACCOUNTS.euVAT : VAT_ACCOUNTS.outputVAT; lines.push({ accountId: `vat-output-${vatCode}`, accountNumber: outputVatAccount, - accountName: vatCode === 'EU_VARE' ? 'EU-moms (erhvervelse)' : 'Udgående moms', + accountName: vatCode === 'IEUV' ? 'EU-moms (erhvervelse)' : 'Udgående moms', description: `Moms: ${description}`, debit: 0, credit: vatAmount, @@ -440,11 +440,11 @@ export function generateSplitDoubleEntry(input: SplitBookingInput): GeneratedTra // For reverse charge, also credit output VAT if (vatConfig.reverseCharge && lineVat > 0) { - const outputVatAccount = splitLine.vatCode === 'EU_VARE' ? VAT_ACCOUNTS.euVAT : VAT_ACCOUNTS.outputVAT; + const outputVatAccount = splitLine.vatCode === 'IEUV' ? VAT_ACCOUNTS.euVAT : VAT_ACCOUNTS.outputVAT; generatedLines.push({ accountId: `vat-output-${splitLine.vatCode}`, accountNumber: outputVatAccount, - accountName: splitLine.vatCode === 'EU_VARE' ? 'EU-moms (erhvervelse)' : 'Udgående moms', + accountName: splitLine.vatCode === 'IEUV' ? 'EU-moms (erhvervelse)' : 'Udgående moms', description: `Moms: ${description}`, debit: 0, credit: lineVat, @@ -541,17 +541,17 @@ export function getSuggestedVATCode(accountNumber: string, isExpense: boolean): // Expenses typically use input VAT (K25) if (isExpense) { // Some expense types are typically VAT-exempt - if (accountType === 'financial') return 'NONE'; - if (accountType === 'personnel') return 'NONE'; - return 'K25'; + if (accountType === 'financial') return 'INGEN'; + if (accountType === 'personnel') return 'INGEN'; + return 'I25'; } - // Revenue typically uses output VAT (S25) + // Revenue typically uses output VAT (U25) if (accountType === 'revenue') { - return 'S25'; + return 'U25'; } - return 'NONE'; + return 'INGEN'; } /** diff --git a/frontend/src/lib/vatCalculation.ts b/frontend/src/lib/vatCalculation.ts index 6176ed0..040ebbc 100644 --- a/frontend/src/lib/vatCalculation.ts +++ b/frontend/src/lib/vatCalculation.ts @@ -159,11 +159,6 @@ function processTransactionLine( const vatCode = line.vatCode; - // Skip lines without VAT relevance - if (vatCode === 'NONE') { - return null; - } - const codeConfig = VAT_CODE_CONFIG[vatCode]; // Calculate net amount (amount without VAT) diff --git a/frontend/src/pages/Bankafstemning.tsx b/frontend/src/pages/Bankafstemning.tsx index 4f1f3de..f0e1645 100644 --- a/frontend/src/pages/Bankafstemning.tsx +++ b/frontend/src/pages/Bankafstemning.tsx @@ -33,7 +33,6 @@ import dayjs from 'dayjs'; import { useNavigate } from 'react-router-dom'; import { useCompany } from '@/hooks/useCompany'; import { useReconciliationStore } from '@/stores/reconciliationStore'; -import { useCompanyStore } from '@/stores/companyStore'; import { useActiveBankConnections } from '@/api/queries/bankConnectionQueries'; import { usePendingBankTransactions } from '@/api/queries/bankTransactionQueries'; import { useActiveAccounts } from '@/api/queries/accountQueries'; @@ -69,12 +68,11 @@ interface MatchSuggestion { export default function Bankafstemning() { const { company } = useCompany(); const navigate = useNavigate(); - const { activeCompany } = useCompanyStore(); // Fetch data from API - const { data: bankConnections = [], isLoading: connectionsLoading } = useActiveBankConnections(activeCompany?.id); - const { data: pendingTransactions = [], isLoading: transactionsLoading } = usePendingBankTransactions(activeCompany?.id); - const { data: activeAccounts = [] } = useActiveAccounts(activeCompany?.id); + const { data: bankConnections = [], isLoading: connectionsLoading } = useActiveBankConnections(company?.id); + const { data: pendingTransactions = [], isLoading: transactionsLoading } = usePendingBankTransactions(company?.id); + const { data: activeAccounts = [] } = useActiveAccounts(company?.id); const isLoading = connectionsLoading || transactionsLoading; @@ -82,7 +80,7 @@ export default function Bankafstemning() { const bankAccounts = bankConnections.flatMap(conn => (conn.accounts || []).map(acc => ({ id: acc.accountId, - companyId: activeCompany?.id || '', + companyId: company?.id || '', name: acc.name || acc.iban, bankName: conn.aspspName, accountNumber: acc.iban, @@ -697,9 +695,9 @@ export default function Bankafstemning() { placeholder="Vælg momskode" allowClear options={[ - { value: 'K25', label: 'K25 - Indgående moms 25%' }, - { value: 'S25', label: 'S25 - Udgående moms 25%' }, - { value: 'NONE', label: 'Ingen moms' }, + { value: 'I25', label: 'I25 - Indgående moms 25%' }, + { value: 'U25', label: 'U25 - Udgående moms 25%' }, + { value: 'INGEN', label: 'Ingen moms' }, ]} /> diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 86a7cad..93a3672 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -10,7 +10,6 @@ import { Line, Pie, Column } from '@ant-design/charts'; import { Link } from 'react-router-dom'; import dayjs from 'dayjs'; import { useCompany } from '@/hooks/useCompany'; -import { useCompanyStore } from '@/stores/companyStore'; import { usePeriodStore } from '@/stores/periodStore'; import { useAccountBalances } from '@/api/queries/accountQueries'; import { useInvoices } from '@/api/queries/invoiceQueries'; @@ -45,7 +44,6 @@ interface RecentTransaction { export default function Dashboard() { const { company } = useCompany(); - const { activeCompany } = useCompanyStore(); const { currentFiscalYear } = usePeriodStore(); // Define date interval - always format as YYYY-MM-DD for GraphQL DateOnly type @@ -57,16 +55,16 @@ export default function Dashboard() { : dayjs().endOf('year').format('YYYY-MM-DD'); const { data: balances = [], isLoading: balancesLoading } = useAccountBalances( - activeCompany?.id, + company?.id, currentFiscalYear ? { startDate: dayjs(currentFiscalYear.startDate), endDate: dayjs(currentFiscalYear.endDate), } : undefined ); - const { data: invoices = [], isLoading: invoicesLoading } = useInvoices(activeCompany?.id); + const { data: invoices = [], isLoading: invoicesLoading } = useInvoices(company?.id); const { data: vatReport, isLoading: vatLoading } = useVatReport( - activeCompany?.id, + company?.id, periodStart, periodEnd ); @@ -252,7 +250,6 @@ export default function Dashboard() { value={metrics.cashPosition} precision={2} prefix={} - suffix="kr." formatter={(value) => formatCurrency(value as number)} />
@@ -270,7 +267,6 @@ export default function Dashboard() { title="Tilgodehavender" value={metrics.accountsReceivable} precision={2} - suffix="kr." valueStyle={{ color: accountingColors.credit }} formatter={(value) => formatCurrency(value as number)} /> @@ -294,7 +290,6 @@ export default function Dashboard() { title="Kreditorer" value={metrics.accountsPayable} precision={2} - suffix="kr." valueStyle={{ color: accountingColors.debit }} formatter={(value) => formatCurrency(value as number)} /> @@ -313,7 +308,6 @@ export default function Dashboard() { title="Moms til betaling" value={metrics.vatLiability} precision={2} - suffix="kr." formatter={(value) => formatCurrency(value as number)} />
diff --git a/frontend/src/pages/Eksport.tsx b/frontend/src/pages/Eksport.tsx index 3827f1b..577fb58 100644 --- a/frontend/src/pages/Eksport.tsx +++ b/frontend/src/pages/Eksport.tsx @@ -48,7 +48,7 @@ export default function Eksport() { } } catch (error) { showError('Der opstod en fejl under eksport'); - console.error('SAF-T export error:', error); + if (import.meta.env.DEV) console.error('SAF-T export error:', error); } }; diff --git a/frontend/src/pages/Fakturaer.tsx b/frontend/src/pages/Fakturaer.tsx index fc80eec..b312579 100644 --- a/frontend/src/pages/Fakturaer.tsx +++ b/frontend/src/pages/Fakturaer.tsx @@ -223,7 +223,7 @@ export default function Fakturaer() { const handleAddLine = () => { setEditingLine(null); lineForm.resetFields(); - lineForm.setFieldsValue({ quantity: 1, vatCode: 'S25' }); + lineForm.setFieldsValue({ quantity: 1, vatCode: 'U25' }); setIsLineModalOpen(true); }; @@ -862,7 +862,7 @@ export default function Fakturaer() { lineForm.setFieldsValue({ description: product.description || product.name, unitPrice: product.unitPrice, - vatCode: product.vatCode === 'U25' ? 'S25' : product.vatCode, + vatCode: product.vatCode, unit: product.unit || undefined, }); } @@ -930,7 +930,7 @@ export default function Fakturaer() { > ( dayjs().subtract(1, 'month').startOf('month') ); const [isPreviewOpen, setIsPreviewOpen] = useState(false); - const [periodType, setPeriodType] = useState<'monthly' | 'quarterly'>('quarterly'); + const [periodType, setPeriodType] = useState<'monthly' | 'quarterly' | 'half-yearly' | 'yearly'>('quarterly'); // Calculate period dates based on selection const periodStart = useMemo(() => { if (periodType === 'quarterly') { return selectedPeriod.startOf('quarter').format('YYYY-MM-DD'); } + if (periodType === 'half-yearly') { + const month = selectedPeriod.month(); // 0-indexed + const halfStart = month < 6 ? 0 : 6; + return selectedPeriod.month(halfStart).startOf('month').format('YYYY-MM-DD'); + } + if (periodType === 'yearly') { + return selectedPeriod.startOf('year').format('YYYY-MM-DD'); + } return selectedPeriod.startOf('month').format('YYYY-MM-DD'); }, [selectedPeriod, periodType]); @@ -66,12 +72,20 @@ export default function Momsindberetning() { if (periodType === 'quarterly') { return selectedPeriod.endOf('quarter').format('YYYY-MM-DD'); } + if (periodType === 'half-yearly') { + const month = selectedPeriod.month(); // 0-indexed + const halfEnd = month < 6 ? 5 : 11; + return selectedPeriod.month(halfEnd).endOf('month').format('YYYY-MM-DD'); + } + if (periodType === 'yearly') { + return selectedPeriod.endOf('year').format('YYYY-MM-DD'); + } return selectedPeriod.endOf('month').format('YYYY-MM-DD'); }, [selectedPeriod, periodType]); // Fetch VAT report from backend const { data: vatReport, isLoading, error } = useVatReport( - activeCompany?.id, + company?.id, periodStart, periodEnd ); @@ -255,20 +269,44 @@ export default function Momsindberetning() { diff --git a/frontend/src/pages/UserSettings.tsx b/frontend/src/pages/UserSettings.tsx index 55ee59b..149a7da 100644 --- a/frontend/src/pages/UserSettings.tsx +++ b/frontend/src/pages/UserSettings.tsx @@ -308,7 +308,7 @@ export default function UserSettings() { Modtag vigtige opdateringer om din konto
- +
@@ -324,7 +324,7 @@ export default function UserSettings() { Få en opsummering af ugens bogføring hver mandag - + @@ -340,7 +340,7 @@ export default function UserSettings() { Få besked når momsfrister eller andre deadlines nærmer sig - + @@ -360,7 +360,7 @@ export default function UserSettings() { Modtag notifikationer direkte i browseren - + diff --git a/frontend/src/types/vat.ts b/frontend/src/types/vat.ts index 3678ee6..ef4f8b6 100644 --- a/frontend/src/types/vat.ts +++ b/frontend/src/types/vat.ts @@ -19,15 +19,7 @@ export type VATCode = | 'IVV' // Import varer verden (0%) | 'IVY' // Import ydelser verden (0%) | 'REP' // Repræsentation (25%, 25% fradrag) - | 'INGEN' // Ingen moms - // Legacy codes kept for backwards compatibility with other modules - | 'S25' // @deprecated Use U25 - | 'K25' // @deprecated Use I25 - | 'EU_VARE' // @deprecated Use IEUV - | 'EU_YDELSE' // @deprecated Use IEUY - | 'MOMSFRI' // @deprecated Use INGEN - | 'EKSPORT' // @deprecated Use UEXP - | 'NONE'; // @deprecated Use INGEN + | 'INGEN'; // Ingen moms /** * VAT code type classification