Audit v4: VAT calc, SAF-T compliance, security hardening, frontend quality

Backend (17 files):
- VAT: REP 25% deductibility (§42), EU reverse charge double-entry (IEUV/IEUY/IVY),
  IVY rate 0%→25%, VatReport Box C/D populated, Basis1 from real revenue
- SAF-T: correct OECD namespace, closing balance net calc, zero-amount fallback,
  credit note auto-numbering (§52)
- Security: BankingController CSRF state token + company auth check,
  attachment canonical path traversal check, discount 0-100% validation,
  deactivated product/customer update guard
- Quality: redact bank API logs, remove dead code (VatCalcService,
  PaymentMatchingService), CompanyAggregate IEmit interfaces, fix URL encoding

Frontend (15 files):
- Fix double "kr." in AmountText and Dashboard Statistic components
- Fix UserSettings Switch defaultChecked desync with Form state
- Remove dual useCompany/useCompanyStore pattern (Dashboard, Moms, Bank)
- Correct SKAT VAT deadline calculation per period type
- Add half-yearly/yearly VAT period options
- Guard console.error with import.meta.env.DEV
- Use shared formatDate in BankConnectionsTab
- Remove dead NONE vatCode check, purge 7 legacy VAT codes from type union
- Migrate S25→U25, K25→I25 across all pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nicolaj Hartmann 2026-02-06 01:38:52 +01:00
parent a7d76df3a7
commit 8096a19081
32 changed files with 380 additions and 163 deletions

View file

@ -128,9 +128,9 @@ public class EnableBankingClient : IEnableBankingClient, IDisposable
var response = await _httpClient.SendAsync(request, ct); var response = await _httpClient.SendAsync(request, ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
// Log raw response for debugging // Log raw response for debugging (sensitive - only at Debug level)
var rawJson = await response.Content.ReadAsStringAsync(ct); 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<SessionApiResponse>(rawJson, _jsonOptions); var result = System.Text.Json.JsonSerializer.Deserialize<SessionApiResponse>(rawJson, _jsonOptions);
var sessionId = result!.SessionId; var sessionId = result!.SessionId;
@ -225,7 +225,7 @@ public class EnableBankingClient : IEnableBankingClient, IDisposable
var response = await _httpClient.SendAsync(request, ct); var response = await _httpClient.SendAsync(request, ct);
var rawJson = await response.Content.ReadAsStringAsync(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(); response.EnsureSuccessStatusCode();

View file

@ -225,21 +225,32 @@ public class VoidInvoiceCommandHandler
// CREDIT NOTE COMMAND HANDLERS // CREDIT NOTE COMMAND HANDLERS
// ===================================================== // =====================================================
public class CreateCreditNoteCommandHandler /// <summary>
/// Command handler for creating credit notes.
/// Auto-assigns sequential credit note numbers (Momsloven §52 - sequential numbering required).
/// </summary>
public class CreateCreditNoteCommandHandler(
IInvoiceNumberService invoiceNumberService)
: CommandHandler<InvoiceAggregate, InvoiceId, CreateCreditNoteCommand> : CommandHandler<InvoiceAggregate, InvoiceId, CreateCreditNoteCommand>
{ {
public override Task ExecuteAsync( public override async Task ExecuteAsync(
InvoiceAggregate aggregate, InvoiceAggregate aggregate,
CreateCreditNoteCommand command, CreateCreditNoteCommand command,
CancellationToken cancellationToken) 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( aggregate.CreateCreditNote(
command.CompanyId, command.CompanyId,
command.FiscalYearId, command.FiscalYearId,
command.CustomerId, command.CustomerId,
command.CustomerName, command.CustomerName,
command.CustomerNumber, command.CustomerNumber,
command.CreditNoteNumber, creditNoteNumber,
command.CreditNoteDate, command.CreditNoteDate,
command.Currency, command.Currency,
command.VatCode, command.VatCode,
@ -249,8 +260,6 @@ public class CreateCreditNoteCommandHandler
command.OriginalInvoiceId, command.OriginalInvoiceId,
command.OriginalInvoiceNumber, command.OriginalInvoiceNumber,
command.CreditReason); command.CreditReason);
return Task.CompletedTask;
} }
} }

View file

@ -185,13 +185,15 @@ public class AttachmentController(
[HttpGet("{*storagePath}")] [HttpGet("{*storagePath}")]
public async Task<IActionResult> Download(string storagePath, CancellationToken cancellationToken) public async Task<IActionResult> Download(string storagePath, CancellationToken cancellationToken)
{ {
// Validate path to prevent directory traversal attacks // Validate path to prevent directory traversal attacks using canonical path resolution
if (string.IsNullOrWhiteSpace(storagePath) || if (string.IsNullOrWhiteSpace(storagePath))
storagePath.Contains("..") || {
storagePath.Contains("~") || return BadRequest(new { error = "INVALID_PATH", message = "Invalid storage path" });
Path.IsPathRooted(storagePath) || }
storagePath.StartsWith("/") ||
storagePath.StartsWith("\\")) 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); logger.LogWarning("Attempted path traversal attack with path: {StoragePath}", storagePath);
return BadRequest(new { error = "INVALID_PATH", message = "Invalid storage path" }); return BadRequest(new { error = "INVALID_PATH", message = "Invalid storage path" });

View file

@ -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.Banking;
using Books.Api.Commands.BankConnections; using Books.Api.Commands.BankConnections;
using Books.Api.Domain.BankConnections; using Books.Api.Domain.BankConnections;
using Books.Api.EventFlow.Repositories;
using EventFlow; using EventFlow;
using EventFlow.Aggregates.ExecutionResults; using EventFlow.Aggregates.ExecutionResults;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -17,17 +22,23 @@ public class BankingController : ControllerBase
private readonly IEnableBankingClient _bankingClient; private readonly IEnableBankingClient _bankingClient;
private readonly ILogger<BankingController> _logger; private readonly ILogger<BankingController> _logger;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly IBankConnectionRepository _bankConnectionRepository;
private readonly ICompanyAccessService _companyAccess;
public BankingController( public BankingController(
ICommandBus commandBus, ICommandBus commandBus,
IEnableBankingClient bankingClient, IEnableBankingClient bankingClient,
ILogger<BankingController> logger, ILogger<BankingController> logger,
IConfiguration configuration) IConfiguration configuration,
IBankConnectionRepository bankConnectionRepository,
ICompanyAccessService companyAccess)
{ {
_commandBus = commandBus; _commandBus = commandBus;
_bankingClient = bankingClient; _bankingClient = bankingClient;
_logger = logger; _logger = logger;
_configuration = configuration; _configuration = configuration;
_bankConnectionRepository = bankConnectionRepository;
_companyAccess = companyAccess;
} }
/// <summary> /// <summary>
@ -60,12 +71,30 @@ public class BankingController : ControllerBase
try try
{ {
// TODO: Add proper CSRF/state validation. Currently the state parameter // Validate HMAC-signed state token to prevent CSRF attacks
// is used as the connection ID, but it should also include a CSRF token var connectionId = ValidateStateToken(state);
// that is validated against the user session to prevent cross-site request if (connectionId == null)
// forgery attacks on the OAuth callback. {
// State contains the connection ID (set during StartBankConnection) _logger.LogWarning("Invalid or tampered state token in bank callback");
var connectionId = state; 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) // Get PSU headers from HttpContext (required by Enable Banking API)
var psuIpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); var psuIpAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
@ -108,4 +137,52 @@ public class BankingController : ControllerBase
return Redirect($"{redirectUrl}&error=internal_error"); return Redirect($"{redirectUrl}&error=internal_error");
} }
} }
/// <summary>
/// Generates an HMAC-signed state token containing the connection ID.
/// Used during StartBankConnection to create a CSRF-safe state parameter.
/// </summary>
public static string GenerateStateToken(string connectionId, string secret)
{
var signature = ComputeHmac(connectionId, secret);
return $"{connectionId}.{signature}";
}
/// <summary>
/// Validates an HMAC-signed state token and extracts the connection ID.
/// Returns null if the token is invalid or tampered with.
/// </summary>
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);
}
} }

View file

@ -3,7 +3,10 @@ using EventFlow.Aggregates;
namespace Books.Api.Domain.Companies; namespace Books.Api.Domain.Companies;
public class CompanyAggregate(CompanyId id) : AggregateRoot<CompanyAggregate, CompanyId>(id) public class CompanyAggregate(CompanyId id) : AggregateRoot<CompanyAggregate, CompanyId>(id),
IEmit<CompanyCreatedEvent>,
IEmit<CompanyUpdatedEvent>,
IEmit<CompanyBankDetailsUpdatedEvent>
{ {
private bool _isCreated; private bool _isCreated;
@ -11,6 +14,8 @@ public class CompanyAggregate(CompanyId id) : AggregateRoot<CompanyAggregate, Co
public void Apply(CompanyUpdatedEvent e) { } public void Apply(CompanyUpdatedEvent e) { }
public void Apply(CompanyBankDetailsUpdatedEvent e) { }
public void Create( public void Create(
string name, string name,
string? cvr, string? cvr,

View file

@ -187,5 +187,10 @@ public class CustomerAggregate(CustomerId id) : AggregateRoot<CustomerAggregate,
throw new DomainException("CUSTOMER_NOT_FOUND", throw new DomainException("CUSTOMER_NOT_FOUND",
"Customer does not exist", "Customer does not exist",
"Kunden findes ikke"); "Kunden findes ikke");
if (!_isActive)
throw new DomainException("CUSTOMER_INACTIVE",
"Cannot modify an inactive customer. Reactivate first.",
"Kan ikke ændre en inaktiv kunde. Genaktiver først.");
} }
} }

View file

@ -199,6 +199,9 @@ public class InvoiceAggregate(InvoiceId id) : AggregateRoot<InvoiceAggregate, In
if (unitPrice < 0) if (unitPrice < 0)
throw new DomainException("INVALID_UNIT_PRICE", "Unit price cannot be negative", "Stykpris kan ikke være negativ"); 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; var lineNumber = _lines.Count > 0 ? _lines.Max(l => l.LineNumber) + 1 : 1;
Emit(new InvoiceLineAddedEvent( Emit(new InvoiceLineAddedEvent(
@ -231,6 +234,9 @@ public class InvoiceAggregate(InvoiceId id) : AggregateRoot<InvoiceAggregate, In
if (!_lines.Any(l => l.LineNumber == lineNumber)) if (!_lines.Any(l => l.LineNumber == lineNumber))
throw new DomainException("LINE_NOT_FOUND", $"Line {lineNumber} not found", $"Linje {lineNumber} findes ikke"); 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( Emit(new InvoiceLineUpdatedEvent(
lineNumber, lineNumber,
description.Trim(), description.Trim(),

View file

@ -147,34 +147,59 @@ public class VatCalculationService : IVatCalculationService
vatAmount = Math.Round(amount * rate, 2, MidpointRounding.AwayFromZero); 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 // Determine if this is input or output VAT
var isInputVat = VatCodes.IsInputVat(line.VatCode); var isInputVat = VatCodes.IsInputVat(line.VatCode);
var vatAccountId = isInputVat ? inputVatAccountId : outputVatAccountId; var vatAccountId = isInputVat ? inputVatAccountId : outputVatAccountId;
var isReverseCharge = line.VatCode is VatCodes.IEUV or VatCodes.IEUY or VatCodes.IVY;
// Create VAT posting line if (isReverseCharge)
// For sales (output VAT): we credit the VAT account
// For purchases (input VAT): we debit the VAT account
var vatLine = new VatPostingLine
{ {
AccountId = vatAccountId, // Reverse charge requires TWO VAT lines:
DebitAmount = isInputVat && isDebit ? vatAmount : (isInputVat ? 0 : 0), // 1. Debit EU acquisition VAT account (5620) - VAT payable
CreditAmount = !isInputVat && !isDebit ? vatAmount : (!isInputVat && isDebit ? vatAmount : 0), // 2. Credit input VAT account (5610) - VAT deductible
Description = $"Moms {line.VatCode} ({rate * 100:0}%)", // These offset each other, resulting in no net VAT effect
VatCode = line.VatCode, const string euAcquisitionVatAccount = "5620";
SourceLineNumber = line.LineNumber
};
// Correct the debit/credit logic: var reverseChargeDebitLine = new VatPostingLine
// - 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) AccountId = euAcquisitionVatAccount,
// The key insight: VAT follows the same direction as the base transaction DebitAmount = isDebit ? vatAmount : 0,
vatLine = vatLine with 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, // Standard VAT posting line (using deductible amount for REP)
CreditAmount = !isDebit ? vatAmount : 0 var vatLine = new VatPostingLine
}; {
AccountId = vatAccountId,
vatLines.Add(vatLine); 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 // Accumulate VAT summary
if (!vatByCode.TryGetValue(line.VatCode, out var summary)) if (!vatByCode.TryGetValue(line.VatCode, out var summary))

View file

@ -229,6 +229,9 @@ public class OrderAggregate(OrderId id) : AggregateRoot<OrderAggregate, OrderId>
if (unitPrice < 0) if (unitPrice < 0)
throw new DomainException("INVALID_UNIT_PRICE", "Unit price cannot be negative", "Stykpris kan ikke være negativ"); 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; var lineNumber = _lines.Count > 0 ? _lines.Max(l => l.LineNumber) + 1 : 1;
Emit(new OrderLineAddedEvent( Emit(new OrderLineAddedEvent(
@ -267,6 +270,9 @@ public class OrderAggregate(OrderId id) : AggregateRoot<OrderAggregate, OrderId>
if (line.IsInvoiced) if (line.IsInvoiced)
throw new DomainException("LINE_ALREADY_INVOICED", $"Line {lineNumber} has already been invoiced", $"Linje {lineNumber} er allerede faktureret"); 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( Emit(new OrderLineUpdatedEvent(
lineNumber, lineNumber,
description.Trim(), description.Trim(),

View file

@ -130,5 +130,10 @@ public class ProductAggregate(ProductId id) : AggregateRoot<ProductAggregate, Pr
throw new DomainException("PRODUCT_NOT_FOUND", throw new DomainException("PRODUCT_NOT_FOUND",
"Product does not exist", "Product does not exist",
"Produktet findes ikke"); "Produktet findes ikke");
if (!_isActive)
throw new DomainException("PRODUCT_INACTIVE",
"Cannot modify an inactive product. Reactivate it first.",
"Kan ikke ændre et inaktivt produkt. Genaktiver det først.");
} }
} }

View file

@ -31,6 +31,7 @@ public static class VatCodes
I25 => 0.25m, I25 => 0.25m,
IEUV => 0.25m, // Reverse charge - calculated but offset IEUV => 0.25m, // Reverse charge - calculated but offset
IEUY => 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 REP => 0.25m, // 25% rate, but only 25% deductible
_ => 0m _ => 0m
}; };
@ -43,6 +44,7 @@ public static class VatCodes
I25 => 1.0m, // 100% deductible I25 => 1.0m, // 100% deductible
IEUV => 1.0m, // 100% deductible (reverse charge) IEUV => 1.0m, // 100% deductible (reverse charge)
IEUY => 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 REP => 0.25m, // Only 25% deductible
_ => 0m _ => 0m
}; };
@ -80,7 +82,7 @@ public static class VatCodes
new(IEUV, "EU-køb varer", "EU Purchase Goods", 0.25m), new(IEUV, "EU-køb varer", "EU Purchase Goods", 0.25m),
new(IEUY, "EU-køb ydelser", "EU Purchase Services", 0.25m), new(IEUY, "EU-køb ydelser", "EU Purchase Services", 0.25m),
new(IVV, "Import varer", "Import Goods", 0m), 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), new(REP, "Repræsentation", "Entertainment", 0.25m),
]; ];
} }

View file

@ -45,6 +45,11 @@ public interface IFileStorageService
/// <param name="expiresIn">URL expiration time</param> /// <param name="expiresIn">URL expiration time</param>
/// <returns>Download URL</returns> /// <returns>Download URL</returns>
string GetDownloadUrl(string storagePath, TimeSpan? expiresIn = null); string GetDownloadUrl(string storagePath, TimeSpan? expiresIn = null);
/// <summary>
/// Gets the base storage path for canonical path validation.
/// </summary>
string GetBasePath();
} }
public record StorageResult public record StorageResult

View file

@ -95,11 +95,15 @@ public class LocalFileStorageService : IFileStorageService
return Task.CompletedTask; return Task.CompletedTask;
} }
public string GetBasePath() => _basePath;
public string GetDownloadUrl(string storagePath, TimeSpan? expiresIn = null) public string GetDownloadUrl(string storagePath, TimeSpan? expiresIn = null)
{ {
// For local storage, return API endpoint URL // For local storage, return API endpoint URL
// The actual download is handled by a controller // 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) private static string SanitizeFileName(string fileName)

View file

@ -512,7 +512,6 @@ public partial class PaymentMatchingService(
.ToUpperInvariant() .ToUpperInvariant()
.Replace("APS", "") .Replace("APS", "")
.Replace("A/S", "") .Replace("A/S", "")
.Replace("ApS", "")
.Replace("IVS", "") .Replace("IVS", "")
.Replace("K/S", "") .Replace("K/S", "")
.Trim(); .Trim();

View file

@ -16,8 +16,9 @@ public class VatReportService(
// Standard Danish VAT account numbers // Standard Danish VAT account numbers
// TODO: These should ideally come from company-level configuration, // TODO: These should ideally come from company-level configuration,
// as different chart-of-accounts templates may use different numbers. // as different chart-of-accounts templates may use different numbers.
private const string InputVatAccountNumber = "5610"; // Købsmoms (indgående moms) private const string InputVatAccountNumber = "5610"; // Købsmoms (indgående moms)
private const string OutputVatAccountNumber = "5611"; // Salgsmoms (udgående moms) private const string OutputVatAccountNumber = "5611"; // Salgsmoms (udgående moms)
private const string EuAcquisitionVatAccountNumber = "5620"; // EU erhvervelsesmoms
public async Task<VatReportDto> GenerateReportAsync( public async Task<VatReportDto> GenerateReportAsync(
string companyId, string companyId,
@ -46,6 +47,8 @@ public class VatReportService(
companyId, InputVatAccountNumber, ct); companyId, InputVatAccountNumber, ct);
var outputVatAccount = await accountRepository.GetByCompanyAndNumberAsync( var outputVatAccount = await accountRepository.GetByCompanyAndNumberAsync(
companyId, OutputVatAccountNumber, ct); companyId, OutputVatAccountNumber, ct);
var euAcquisitionVatAccount = await accountRepository.GetByCompanyAndNumberAsync(
companyId, EuAcquisitionVatAccountNumber, ct);
var report = new VatReportDto var report = new VatReportDto
{ {
@ -53,8 +56,8 @@ public class VatReportService(
PeriodEnd = periodEnd PeriodEnd = periodEnd
}; };
// If neither VAT account exists, return empty report // If no VAT accounts exist, return empty report
if (inputVatAccount == null && outputVatAccount == null) if (inputVatAccount == null && outputVatAccount == null && euAcquisitionVatAccount == null)
{ {
logger.LogWarning( logger.LogWarning(
"No VAT accounts found for company {CompanyId}. Returning empty report.", "No VAT accounts found for company {CompanyId}. Returning empty report.",
@ -75,6 +78,11 @@ public class VatReportService(
accountIds.Add(outputGuid); accountIds.Add(outputGuid);
} }
if (euAcquisitionVatAccount != null && TryParseAccountGuid(euAcquisitionVatAccount.Id, out var euAcquisitionGuid))
{
accountIds.Add(euAcquisitionGuid);
}
if (accountIds.Count == 0) if (accountIds.Count == 0)
{ {
logger.LogWarning("No valid VAT account GUIDs found for company {CompanyId}", companyId); 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}", "Output VAT (5611): TotalDebits={Debits}, TotalCredits={Credits}, NetChange={Net}",
balance.TotalDebits, balance.TotalCredits, balance.NetChange); 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 // Calculate summary totals
// Box A = Salgsmoms (domestic output VAT) // Box A = Salgsmoms (domestic output VAT)
// Box B = Købsmoms (input VAT - deductible) // Box B = Købsmoms (input VAT - deductible)
// Box C = EU-varekø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 (not yet supported - requires VAT code breakdown) // Box D = Ydelseskøb moms (reverse charge on services from abroad)
report.TotalOutputVat = report.BoxA + report.BoxC + report.BoxD; report.TotalOutputVat = report.BoxA + report.BoxC + report.BoxD;
report.TotalInputVat = report.BoxB; report.TotalInputVat = report.BoxB;
report.NetVat = report.TotalOutputVat - report.TotalInputVat; report.NetVat = report.TotalOutputVat - report.TotalInputVat;
// Basis1 (Felt 1): Net domestic turnover with VAT // Basis1 (Felt 1): Net domestic turnover with VAT
// TODO: Query actual net turnover from transactions with output VAT codes (U25) // Query revenue account totals (accounts 1000-1999) for actual turnover basis
// instead of back-calculating from VAT amount, which is inaccurate when // instead of back-calculating from VAT which is inaccurate with mixed rates
// mixed VAT rates or partial deductions are involved. var revenueAccounts = await accountRepository.GetByCompanyIdAsync(companyId, ct);
// Ideally: query revenue account balances filtered by VAT code U25. var revenueAccountIds = new List<Guid>();
// For now, back-calculate from output VAT assuming standard 25% rate foreach (var acc in revenueAccounts)
if (report.BoxA > 0) {
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); 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( logger.LogInformation(
"VAT report generated for company {CompanyId}: OutputVAT={OutputVat}, InputVAT={InputVat}, NetVAT={NetVat}", "VAT report generated for company {CompanyId}: OutputVAT={OutputVat}, InputVAT={InputVat}, NetVAT={NetVat}",
companyId, report.TotalOutputVat, report.TotalInputVat, report.NetVat); companyId, report.TotalOutputVat, report.TotalInputVat, report.NetVat);

View file

@ -212,14 +212,16 @@ public class SaftExportService(
accountBalances.TryGetValue(guid ?? Guid.Empty, out var periodBalance); accountBalances.TryGetValue(guid ?? Guid.Empty, out var periodBalance);
openingBalances.TryGetValue(guid ?? Guid.Empty, out var openingBalance); openingBalances.TryGetValue(guid ?? Guid.Empty, out var openingBalance);
// Opening balance = net balance from all transactions before period start // Compute net balance: debits - credits
// Net balance is calculated as: Debits - Credits for Asset/Expense, Credits - Debits for Liability/Equity/Income // SAF-T expects a single net balance reported as either DebitBalance or CreditBalance
var openingDebit = openingBalance.TotalDebits; var openingNet = openingBalance.TotalDebits - openingBalance.TotalCredits;
var openingCredit = openingBalance.TotalCredits; var openingDebit = openingNet >= 0 ? openingNet : 0m;
var openingCredit = openingNet < 0 ? Math.Abs(openingNet) : 0m;
// Closing balance = Opening balance + Period movements // Closing balance = Opening net + Period net
var closingDebit = openingDebit + periodBalance.TotalDebits; var closingNet = openingNet + (periodBalance.TotalDebits - periodBalance.TotalCredits);
var closingCredit = openingCredit + periodBalance.TotalCredits; var closingDebit = closingNet >= 0 ? closingNet : 0m;
var closingCredit = closingNet < 0 ? Math.Abs(closingNet) : 0m;
return new SaftAccount( return new SaftAccount(
acc.AccountNumber, acc.AccountNumber,

View file

@ -10,7 +10,7 @@ namespace Books.Api.Saft.Services;
/// </summary> /// </summary>
public class SaftXmlBuilder public class SaftXmlBuilder
{ {
private const string SaftNamespace = "urn:StandardAuditFile-Taxation-Financial:DK"; private const string SaftNamespace = "urn:OECD:StandardAuditFile-Taxation/2.00";
/// <summary> /// <summary>
/// Builds a SAF-T XML document from the provided data. /// 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, "IEUV", "EU-erhvervelse varer (reverse charge)", 25.00m);
WriteTaxCodeDetails(writer, "IEUY", "EU-erhvervelse ydelser (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, "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 // Special codes
WriteTaxCodeDetails(writer, "REP", "Repræsentation (25% fradrag)", 25.00m); WriteTaxCodeDetails(writer, "REP", "Repræsentation (25% fradrag)", 25.00m);
@ -347,11 +347,19 @@ public class SaftXmlBuilder
if (!string.IsNullOrEmpty(line.Description)) if (!string.IsNullOrEmpty(line.Description))
writer.WriteElementString("Description", line.Description); writer.WriteElementString("Description", line.Description);
if (line.DebitAmount.HasValue && line.DebitAmount.Value != 0) // SAF-T schema requires at least one of DebitAmount or CreditAmount
writer.WriteElementString("DebitAmount", FormatDecimal(line.DebitAmount.Value)); 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) if (hasDebit)
writer.WriteElementString("CreditAmount", FormatDecimal(line.CreditAmount.Value)); 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)) if (!string.IsNullOrEmpty(line.CustomerID))
writer.WriteElementString("CustomerID", line.CustomerID); writer.WriteElementString("CustomerID", line.CustomerID);

View file

@ -51,8 +51,8 @@ export async function fetchGraphQL<TData, TVariables extends Record<string, unkn
const data = await graphqlClient.request<TData>(query, variables, headers); const data = await graphqlClient.request<TData>(query, variables, headers);
return data; return data;
} catch (error) { } catch (error) {
// Log error for debugging // Log error for debugging (dev only)
console.error('GraphQL Error:', error); if (import.meta.env.DEV) console.error('GraphQL Error:', error);
// Re-throw with more context // Re-throw with more context
if (error instanceof Error) { if (error instanceof Error) {

View file

@ -19,6 +19,7 @@ import {
DatePicker, DatePicker,
} from 'antd'; } from 'antd';
import { showSuccess, showError } from '@/lib/errorHandling'; import { showSuccess, showError } from '@/lib/errorHandling';
import { formatDate } from '@/lib/formatters';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { import {
@ -64,13 +65,6 @@ function getStatusTag(status: BankConnection['status'], isActive: boolean) {
return <Tag>{status}</Tag>; return <Tag>{status}</Tag>;
} }
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('da-DK', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
export default function BankConnectionsTab({ companyId }: BankConnectionsTabProps) { export default function BankConnectionsTab({ companyId }: BankConnectionsTabProps) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();

View file

@ -1,6 +1,6 @@
import { Typography, Tooltip } from 'antd'; import { Typography, Tooltip } from 'antd';
import { ArrowUpOutlined, ArrowDownOutlined, MinusOutlined } from '@ant-design/icons'; 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 { accountingColors } from '@/styles/theme';
import { typography, getAmountColor } from '@/styles/designTokens'; import { typography, getAmountColor } from '@/styles/designTokens';
@ -77,7 +77,7 @@ export function AmountText({
}; };
const formatAmount = (): string => { 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) // Always show +/- prefix for non-zero amounts (accessibility: not color-only)
// When showSign is explicitly true, same behavior; kept for API compatibility // When showSign is explicitly true, same behavior; kept for API compatibility
const alwaysSign = showSign; const alwaysSign = showSign;

View file

@ -138,11 +138,11 @@ export const VAT_RATES = {
* Standard Danish VAT codes * Standard Danish VAT codes
*/ */
export const VAT_CODES = { export const VAT_CODES = {
S25: { code: 'S25', name: 'Udgående moms 25%', rate: 0.25, type: 'output' }, U25: { code: 'U25', name: 'Udgående moms 25%', rate: 0.25, type: 'output' },
K25: { code: 'K25', name: 'Indgående moms 25%', rate: 0.25, type: 'input' }, 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' }, E0: { code: 'E0', name: 'EU-varekøb 0%', rate: 0, type: 'eu' },
U0: { code: 'U0', name: 'Eksport 0%', rate: 0, type: 'export' }, 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; } as const;
/** /**
@ -298,11 +298,11 @@ export function generateSimpleDoubleEntry(input: SimpleBookingInput): GeneratedT
// For reverse charge (EU purchases), also credit output VAT // For reverse charge (EU purchases), also credit output VAT
if (vatConfig.reverseCharge && vatAmount > 0) { 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({ lines.push({
accountId: `vat-output-${vatCode}`, accountId: `vat-output-${vatCode}`,
accountNumber: outputVatAccount, accountNumber: outputVatAccount,
accountName: vatCode === 'EU_VARE' ? 'EU-moms (erhvervelse)' : 'Udgående moms', accountName: vatCode === 'IEUV' ? 'EU-moms (erhvervelse)' : 'Udgående moms',
description: `Moms: ${description}`, description: `Moms: ${description}`,
debit: 0, debit: 0,
credit: vatAmount, credit: vatAmount,
@ -440,11 +440,11 @@ export function generateSplitDoubleEntry(input: SplitBookingInput): GeneratedTra
// For reverse charge, also credit output VAT // For reverse charge, also credit output VAT
if (vatConfig.reverseCharge && lineVat > 0) { 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({ generatedLines.push({
accountId: `vat-output-${splitLine.vatCode}`, accountId: `vat-output-${splitLine.vatCode}`,
accountNumber: outputVatAccount, 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}`, description: `Moms: ${description}`,
debit: 0, debit: 0,
credit: lineVat, credit: lineVat,
@ -541,17 +541,17 @@ export function getSuggestedVATCode(accountNumber: string, isExpense: boolean):
// Expenses typically use input VAT (K25) // Expenses typically use input VAT (K25)
if (isExpense) { if (isExpense) {
// Some expense types are typically VAT-exempt // Some expense types are typically VAT-exempt
if (accountType === 'financial') return 'NONE'; if (accountType === 'financial') return 'INGEN';
if (accountType === 'personnel') return 'NONE'; if (accountType === 'personnel') return 'INGEN';
return 'K25'; return 'I25';
} }
// Revenue typically uses output VAT (S25) // Revenue typically uses output VAT (U25)
if (accountType === 'revenue') { if (accountType === 'revenue') {
return 'S25'; return 'U25';
} }
return 'NONE'; return 'INGEN';
} }
/** /**

View file

@ -159,11 +159,6 @@ function processTransactionLine(
const vatCode = line.vatCode; const vatCode = line.vatCode;
// Skip lines without VAT relevance
if (vatCode === 'NONE') {
return null;
}
const codeConfig = VAT_CODE_CONFIG[vatCode]; const codeConfig = VAT_CODE_CONFIG[vatCode];
// Calculate net amount (amount without VAT) // Calculate net amount (amount without VAT)

View file

@ -33,7 +33,6 @@ import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useCompany } from '@/hooks/useCompany'; import { useCompany } from '@/hooks/useCompany';
import { useReconciliationStore } from '@/stores/reconciliationStore'; import { useReconciliationStore } from '@/stores/reconciliationStore';
import { useCompanyStore } from '@/stores/companyStore';
import { useActiveBankConnections } from '@/api/queries/bankConnectionQueries'; import { useActiveBankConnections } from '@/api/queries/bankConnectionQueries';
import { usePendingBankTransactions } from '@/api/queries/bankTransactionQueries'; import { usePendingBankTransactions } from '@/api/queries/bankTransactionQueries';
import { useActiveAccounts } from '@/api/queries/accountQueries'; import { useActiveAccounts } from '@/api/queries/accountQueries';
@ -69,12 +68,11 @@ interface MatchSuggestion {
export default function Bankafstemning() { export default function Bankafstemning() {
const { company } = useCompany(); const { company } = useCompany();
const navigate = useNavigate(); const navigate = useNavigate();
const { activeCompany } = useCompanyStore();
// Fetch data from API // Fetch data from API
const { data: bankConnections = [], isLoading: connectionsLoading } = useActiveBankConnections(activeCompany?.id); const { data: bankConnections = [], isLoading: connectionsLoading } = useActiveBankConnections(company?.id);
const { data: pendingTransactions = [], isLoading: transactionsLoading } = usePendingBankTransactions(activeCompany?.id); const { data: pendingTransactions = [], isLoading: transactionsLoading } = usePendingBankTransactions(company?.id);
const { data: activeAccounts = [] } = useActiveAccounts(activeCompany?.id); const { data: activeAccounts = [] } = useActiveAccounts(company?.id);
const isLoading = connectionsLoading || transactionsLoading; const isLoading = connectionsLoading || transactionsLoading;
@ -82,7 +80,7 @@ export default function Bankafstemning() {
const bankAccounts = bankConnections.flatMap(conn => const bankAccounts = bankConnections.flatMap(conn =>
(conn.accounts || []).map(acc => ({ (conn.accounts || []).map(acc => ({
id: acc.accountId, id: acc.accountId,
companyId: activeCompany?.id || '', companyId: company?.id || '',
name: acc.name || acc.iban, name: acc.name || acc.iban,
bankName: conn.aspspName, bankName: conn.aspspName,
accountNumber: acc.iban, accountNumber: acc.iban,
@ -697,9 +695,9 @@ export default function Bankafstemning() {
placeholder="Vælg momskode" placeholder="Vælg momskode"
allowClear allowClear
options={[ options={[
{ value: 'K25', label: 'K25 - Indgående moms 25%' }, { value: 'I25', label: 'I25 - Indgående moms 25%' },
{ value: 'S25', label: 'S25 - Udgående moms 25%' }, { value: 'U25', label: 'U25 - Udgående moms 25%' },
{ value: 'NONE', label: 'Ingen moms' }, { value: 'INGEN', label: 'Ingen moms' },
]} ]}
/> />
</Form.Item> </Form.Item>

View file

@ -10,7 +10,6 @@ import { Line, Pie, Column } from '@ant-design/charts';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useCompany } from '@/hooks/useCompany'; import { useCompany } from '@/hooks/useCompany';
import { useCompanyStore } from '@/stores/companyStore';
import { usePeriodStore } from '@/stores/periodStore'; import { usePeriodStore } from '@/stores/periodStore';
import { useAccountBalances } from '@/api/queries/accountQueries'; import { useAccountBalances } from '@/api/queries/accountQueries';
import { useInvoices } from '@/api/queries/invoiceQueries'; import { useInvoices } from '@/api/queries/invoiceQueries';
@ -45,7 +44,6 @@ interface RecentTransaction {
export default function Dashboard() { export default function Dashboard() {
const { company } = useCompany(); const { company } = useCompany();
const { activeCompany } = useCompanyStore();
const { currentFiscalYear } = usePeriodStore(); const { currentFiscalYear } = usePeriodStore();
// Define date interval - always format as YYYY-MM-DD for GraphQL DateOnly type // 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'); : dayjs().endOf('year').format('YYYY-MM-DD');
const { data: balances = [], isLoading: balancesLoading } = useAccountBalances( const { data: balances = [], isLoading: balancesLoading } = useAccountBalances(
activeCompany?.id, company?.id,
currentFiscalYear ? { currentFiscalYear ? {
startDate: dayjs(currentFiscalYear.startDate), startDate: dayjs(currentFiscalYear.startDate),
endDate: dayjs(currentFiscalYear.endDate), endDate: dayjs(currentFiscalYear.endDate),
} : undefined } : undefined
); );
const { data: invoices = [], isLoading: invoicesLoading } = useInvoices(activeCompany?.id); const { data: invoices = [], isLoading: invoicesLoading } = useInvoices(company?.id);
const { data: vatReport, isLoading: vatLoading } = useVatReport( const { data: vatReport, isLoading: vatLoading } = useVatReport(
activeCompany?.id, company?.id,
periodStart, periodStart,
periodEnd periodEnd
); );
@ -252,7 +250,6 @@ export default function Dashboard() {
value={metrics.cashPosition} value={metrics.cashPosition}
precision={2} precision={2}
prefix={<BankOutlined />} prefix={<BankOutlined />}
suffix="kr."
formatter={(value) => formatCurrency(value as number)} formatter={(value) => formatCurrency(value as number)}
/> />
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
@ -270,7 +267,6 @@ export default function Dashboard() {
title="Tilgodehavender" title="Tilgodehavender"
value={metrics.accountsReceivable} value={metrics.accountsReceivable}
precision={2} precision={2}
suffix="kr."
valueStyle={{ color: accountingColors.credit }} valueStyle={{ color: accountingColors.credit }}
formatter={(value) => formatCurrency(value as number)} formatter={(value) => formatCurrency(value as number)}
/> />
@ -294,7 +290,6 @@ export default function Dashboard() {
title="Kreditorer" title="Kreditorer"
value={metrics.accountsPayable} value={metrics.accountsPayable}
precision={2} precision={2}
suffix="kr."
valueStyle={{ color: accountingColors.debit }} valueStyle={{ color: accountingColors.debit }}
formatter={(value) => formatCurrency(value as number)} formatter={(value) => formatCurrency(value as number)}
/> />
@ -313,7 +308,6 @@ export default function Dashboard() {
title="Moms til betaling" title="Moms til betaling"
value={metrics.vatLiability} value={metrics.vatLiability}
precision={2} precision={2}
suffix="kr."
formatter={(value) => formatCurrency(value as number)} formatter={(value) => formatCurrency(value as number)}
/> />
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>

View file

@ -48,7 +48,7 @@ export default function Eksport() {
} }
} catch (error) { } catch (error) {
showError('Der opstod en fejl under eksport'); 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);
} }
}; };

View file

@ -223,7 +223,7 @@ export default function Fakturaer() {
const handleAddLine = () => { const handleAddLine = () => {
setEditingLine(null); setEditingLine(null);
lineForm.resetFields(); lineForm.resetFields();
lineForm.setFieldsValue({ quantity: 1, vatCode: 'S25' }); lineForm.setFieldsValue({ quantity: 1, vatCode: 'U25' });
setIsLineModalOpen(true); setIsLineModalOpen(true);
}; };
@ -862,7 +862,7 @@ export default function Fakturaer() {
lineForm.setFieldsValue({ lineForm.setFieldsValue({
description: product.description || product.name, description: product.description || product.name,
unitPrice: product.unitPrice, unitPrice: product.unitPrice,
vatCode: product.vatCode === 'U25' ? 'S25' : product.vatCode, vatCode: product.vatCode,
unit: product.unit || undefined, unit: product.unit || undefined,
}); });
} }
@ -930,7 +930,7 @@ export default function Fakturaer() {
> >
<Select <Select
options={[ options={[
{ value: 'S25', label: 'S25 - 25% moms' }, { value: 'U25', label: 'U25 - 25% moms' },
{ value: 'U0', label: 'U0 - Momsfrit' }, { value: 'U0', label: 'U0 - Momsfrit' },
{ value: 'UEU', label: 'UEU - EU-salg' }, { value: 'UEU', label: 'UEU - EU-salg' },
{ value: 'UEXP', label: 'UEXP - Eksport' }, { value: 'UEXP', label: 'UEXP - Eksport' },

View file

@ -424,8 +424,8 @@ export default function Kontooversigt() {
allowClear allowClear
placeholder="Vælg..." placeholder="Vælg..."
options={[ options={[
{ value: 'S25', label: 'S25 - Udgående (Salg)' }, { value: 'U25', label: 'U25 - Udgående (Salg)' },
{ value: 'K25', label: 'K25 - Indgående (Køb)' }, { value: 'I25', label: 'I25 - Indgående (Køb)' },
{ value: 'E0', label: 'E0 - EU-salg' }, { value: 'E0', label: 'E0 - EU-salg' },
{ value: 'U0', label: 'U0 - Eksport' }, { value: 'U0', label: 'U0 - Eksport' },
]} ]}

View file

@ -211,7 +211,7 @@ export default function Kreditnotaer() {
const handleAddLine = () => { const handleAddLine = () => {
setEditingLine(null); setEditingLine(null);
lineForm.resetFields(); lineForm.resetFields();
lineForm.setFieldsValue({ quantity: 1, vatCode: 'S25' }); lineForm.setFieldsValue({ quantity: 1, vatCode: 'U25' });
setIsLineModalOpen(true); setIsLineModalOpen(true);
}; };
@ -854,7 +854,7 @@ export default function Kreditnotaer() {
> >
<Select <Select
options={[ options={[
{ value: 'S25', label: 'S25 - 25% moms' }, { value: 'U25', label: 'U25 - 25% moms' },
{ value: 'U0', label: 'U0 - Momsfrit' }, { value: 'U0', label: 'U0 - Momsfrit' },
{ value: 'UEU', label: 'UEU - EU-salg' }, { value: 'UEU', label: 'UEU - EU-salg' },
{ value: 'UEXP', label: 'UEXP - Eksport' }, { value: 'UEXP', label: 'UEXP - Eksport' },

View file

@ -26,7 +26,6 @@ import {
import { Pie } from '@ant-design/charts'; import { Pie } from '@ant-design/charts';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useCompany } from '@/hooks/useCompany'; import { useCompany } from '@/hooks/useCompany';
import { useCompanyStore } from '@/stores/companyStore';
import { useVatReport } from '@/api/queries/vatQueries'; import { useVatReport } from '@/api/queries/vatQueries';
import { formatCurrency, formatPeriod } from '@/lib/formatters'; import { formatCurrency, formatPeriod } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme'; import { accountingColors } from '@/styles/theme';
@ -47,18 +46,25 @@ interface VATBox {
export default function Momsindberetning() { export default function Momsindberetning() {
const { company } = useCompany(); const { company } = useCompany();
const { activeCompany } = useCompanyStore();
const [selectedPeriod, setSelectedPeriod] = useState<dayjs.Dayjs>( const [selectedPeriod, setSelectedPeriod] = useState<dayjs.Dayjs>(
dayjs().subtract(1, 'month').startOf('month') dayjs().subtract(1, 'month').startOf('month')
); );
const [isPreviewOpen, setIsPreviewOpen] = useState(false); 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 // Calculate period dates based on selection
const periodStart = useMemo(() => { const periodStart = useMemo(() => {
if (periodType === 'quarterly') { if (periodType === 'quarterly') {
return selectedPeriod.startOf('quarter').format('YYYY-MM-DD'); 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'); return selectedPeriod.startOf('month').format('YYYY-MM-DD');
}, [selectedPeriod, periodType]); }, [selectedPeriod, periodType]);
@ -66,12 +72,20 @@ export default function Momsindberetning() {
if (periodType === 'quarterly') { if (periodType === 'quarterly') {
return selectedPeriod.endOf('quarter').format('YYYY-MM-DD'); 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'); return selectedPeriod.endOf('month').format('YYYY-MM-DD');
}, [selectedPeriod, periodType]); }, [selectedPeriod, periodType]);
// Fetch VAT report from backend // Fetch VAT report from backend
const { data: vatReport, isLoading, error } = useVatReport( const { data: vatReport, isLoading, error } = useVatReport(
activeCompany?.id, company?.id,
periodStart, periodStart,
periodEnd periodEnd
); );
@ -255,20 +269,44 @@ export default function Momsindberetning() {
<Select <Select
value={periodType} value={periodType}
onChange={setPeriodType} onChange={setPeriodType}
style={{ width: 120 }} style={{ width: 140 }}
options={[ options={[
{ value: 'monthly', label: 'Månedlig' }, { value: 'monthly', label: 'Månedlig' },
{ value: 'quarterly', label: 'Kvartalsvis' }, { value: 'quarterly', label: 'Kvartalsvis' },
{ value: 'half-yearly', label: 'Halvårlig' },
{ value: 'yearly', label: 'Årlig' },
]} ]}
/> />
<DatePicker <DatePicker
picker={periodType === 'quarterly' ? 'quarter' : 'month'} picker={periodType === 'quarterly' ? 'quarter' : periodType === 'yearly' ? 'year' : 'month'}
value={selectedPeriod} value={selectedPeriod}
onChange={(date) => date && setSelectedPeriod(date)} onChange={(date) => date && setSelectedPeriod(date)}
format={periodType === 'quarterly' ? '[Q]Q YYYY' : 'MMMM YYYY'} format={
periodType === 'quarterly' ? '[Q]Q YYYY'
: periodType === 'yearly' ? 'YYYY'
: 'MMMM YYYY'
}
/> />
<Tag color="blue"> <Tag color="blue">
Frist: {dayjs(selectedPeriod).add(1, 'month').endOf('month').format('D. MMMM YYYY')} Frist: {(() => {
const pEnd = dayjs(periodEnd);
switch (periodType) {
case 'monthly':
// 25th of next month
return pEnd.add(1, 'month').date(25).format('D. MMMM YYYY');
case 'quarterly':
// 1st day of 2nd month after quarter end
return pEnd.add(2, 'month').startOf('month').format('D. MMMM YYYY');
case 'half-yearly':
// 1st day of 3rd month after half-year end
return pEnd.add(3, 'month').startOf('month').format('D. MMMM YYYY');
case 'yearly':
// 6 months after fiscal year end
return pEnd.add(6, 'month').format('D. MMMM YYYY');
default:
return pEnd.add(1, 'month').endOf('month').format('D. MMMM YYYY');
}
})()}
</Tag> </Tag>
{vatReport && ( {vatReport && (
<Tag color="green">{vatReport.transactionCount} transaktioner</Tag> <Tag color="green">{vatReport.transactionCount} transaktioner</Tag>

View file

@ -178,7 +178,7 @@ export default function Ordrer() {
addLineForm.resetFields(); addLineForm.resetFields();
addLineForm.setFieldsValue({ addLineForm.setFieldsValue({
quantity: 1, quantity: 1,
vatCode: 'S25', vatCode: 'U25',
}); });
setAddLineMode('product'); setAddLineMode('product');
setSelectedProductId(null); setSelectedProductId(null);
@ -193,7 +193,7 @@ export default function Ordrer() {
description: product.name, description: product.name,
unitPrice: product.unitPrice, unitPrice: product.unitPrice,
unit: product.unit || 'stk', unit: product.unit || 'stk',
vatCode: product.vatCode || 'S25', vatCode: product.vatCode || 'U25',
}); });
} }
}; };
@ -825,7 +825,7 @@ export default function Ordrer() {
addLineForm.resetFields(); addLineForm.resetFields();
addLineForm.setFieldsValue({ addLineForm.setFieldsValue({
quantity: 1, quantity: 1,
vatCode: 'S25', vatCode: 'U25',
}); });
}} }}
optionType="button" optionType="button"
@ -918,7 +918,7 @@ export default function Ordrer() {
<Select <Select
disabled={addLineMode === 'product' && !!selectedProductId} disabled={addLineMode === 'product' && !!selectedProductId}
options={[ options={[
{ value: 'S25', label: 'S25 - Salgsmoms 25%' }, { value: 'U25', label: 'U25 - Salgsmoms 25%' },
{ value: 'S0', label: 'S0 - Momsfrit salg' }, { value: 'S0', label: 'S0 - Momsfrit salg' },
]} ]}
/> />

View file

@ -308,7 +308,7 @@ export default function UserSettings() {
Modtag vigtige opdateringer om din konto Modtag vigtige opdateringer om din konto
</Text> </Text>
</div> </div>
<Switch defaultChecked={mockUser.notifications.email} /> <Switch />
</div> </div>
</Form.Item> </Form.Item>
@ -324,7 +324,7 @@ export default function UserSettings() {
en opsummering af ugens bogføring hver mandag en opsummering af ugens bogføring hver mandag
</Text> </Text>
</div> </div>
<Switch defaultChecked={mockUser.notifications.weeklyReport} /> <Switch />
</div> </div>
</Form.Item> </Form.Item>
@ -340,7 +340,7 @@ export default function UserSettings() {
besked når momsfrister eller andre deadlines nærmer sig besked når momsfrister eller andre deadlines nærmer sig
</Text> </Text>
</div> </div>
<Switch defaultChecked={mockUser.notifications.deadlineReminders} /> <Switch />
</div> </div>
</Form.Item> </Form.Item>
@ -360,7 +360,7 @@ export default function UserSettings() {
Modtag notifikationer direkte i browseren Modtag notifikationer direkte i browseren
</Text> </Text>
</div> </div>
<Switch defaultChecked={mockUser.notifications.browser} /> <Switch />
</div> </div>
</Form.Item> </Form.Item>

View file

@ -19,15 +19,7 @@ export type VATCode =
| 'IVV' // Import varer verden (0%) | 'IVV' // Import varer verden (0%)
| 'IVY' // Import ydelser verden (0%) | 'IVY' // Import ydelser verden (0%)
| 'REP' // Repræsentation (25%, 25% fradrag) | 'REP' // Repræsentation (25%, 25% fradrag)
| 'INGEN' // Ingen moms | '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
/** /**
* VAT code type classification * VAT code type classification