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);
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<SessionApiResponse>(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();

View file

@ -225,21 +225,32 @@ public class VoidInvoiceCommandHandler
// 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>
{
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;
}
}

View file

@ -185,13 +185,15 @@ public class AttachmentController(
[HttpGet("{*storagePath}")]
public async Task<IActionResult> 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" });

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.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<BankingController> _logger;
private readonly IConfiguration _configuration;
private readonly IBankConnectionRepository _bankConnectionRepository;
private readonly ICompanyAccessService _companyAccess;
public BankingController(
ICommandBus commandBus,
IEnableBankingClient bankingClient,
ILogger<BankingController> logger,
IConfiguration configuration)
IConfiguration configuration,
IBankConnectionRepository bankConnectionRepository,
ICompanyAccessService companyAccess)
{
_commandBus = commandBus;
_bankingClient = bankingClient;
_logger = logger;
_configuration = configuration;
_bankConnectionRepository = bankConnectionRepository;
_companyAccess = companyAccess;
}
/// <summary>
@ -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");
}
}
/// <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;
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;
@ -11,6 +14,8 @@ public class CompanyAggregate(CompanyId id) : AggregateRoot<CompanyAggregate, Co
public void Apply(CompanyUpdatedEvent e) { }
public void Apply(CompanyBankDetailsUpdatedEvent e) { }
public void Create(
string name,
string? cvr,

View file

@ -187,5 +187,10 @@ public class CustomerAggregate(CustomerId id) : AggregateRoot<CustomerAggregate,
throw new DomainException("CUSTOMER_NOT_FOUND",
"Customer does not exist",
"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)
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 InvoiceLineAddedEvent(
@ -231,6 +234,9 @@ public class InvoiceAggregate(InvoiceId id) : AggregateRoot<InvoiceAggregate, In
if (!_lines.Any(l => 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(),

View file

@ -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))

View file

@ -229,6 +229,9 @@ public class OrderAggregate(OrderId id) : AggregateRoot<OrderAggregate, OrderId>
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<OrderAggregate, OrderId>
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(),

View file

@ -130,5 +130,10 @@ public class ProductAggregate(ProductId id) : AggregateRoot<ProductAggregate, Pr
throw new DomainException("PRODUCT_NOT_FOUND",
"Product does not exist",
"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,
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),
];
}

View file

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

View file

@ -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)

View file

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

View file

@ -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<VatReportDto> 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<Guid>();
foreach (var acc in revenueAccounts)
{
if (int.TryParse(acc.AccountNumber, out var num) && num >= 1000 && num <= 1999 &&
TryParseAccountGuid(acc.Id, out var revGuid))
{
revenueAccountIds.Add(revGuid);
}
}
if (revenueAccountIds.Count > 0)
{
var revenueQuery = new EntriesQuery
{
AccountIds = revenueAccountIds,
From = new DateTimeOffset(periodStart.ToDateTime(TimeOnly.MinValue)),
To = new DateTimeOffset(periodEnd.ToDateTime(TimeOnly.MaxValue)),
Aggregate = true
};
var revenueResult = await ledgerService.QueryEntriesAsync(revenueQuery, ct);
if (revenueResult.Aggregates != null)
{
// Revenue accounts have credits for income; sum the credits
report.Basis1 = revenueResult.Aggregates.Sum(a => a.TotalCredits);
}
}
// 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);

View file

@ -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,

View file

@ -10,7 +10,7 @@ namespace Books.Api.Saft.Services;
/// </summary>
public class SaftXmlBuilder
{
private const string SaftNamespace = "urn:StandardAuditFile-Taxation-Financial:DK";
private const string SaftNamespace = "urn:OECD:StandardAuditFile-Taxation/2.00";
/// <summary>
/// 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);

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);
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) {

View file

@ -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 <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) {
const [searchParams, setSearchParams] = useSearchParams();

View file

@ -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;

View file

@ -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';
}
/**

View file

@ -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)

View file

@ -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' },
]}
/>
</Form.Item>

View file

@ -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={<BankOutlined />}
suffix="kr."
formatter={(value) => formatCurrency(value as number)}
/>
<div style={{ marginTop: 8 }}>
@ -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)}
/>
<div style={{ marginTop: 8 }}>

View file

@ -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);
}
};

View file

@ -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() {
>
<Select
options={[
{ value: 'S25', label: 'S25 - 25% moms' },
{ value: 'U25', label: 'U25 - 25% moms' },
{ value: 'U0', label: 'U0 - Momsfrit' },
{ value: 'UEU', label: 'UEU - EU-salg' },
{ value: 'UEXP', label: 'UEXP - Eksport' },

View file

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

View file

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

View file

@ -26,7 +26,6 @@ import {
import { Pie } from '@ant-design/charts';
import dayjs from 'dayjs';
import { useCompany } from '@/hooks/useCompany';
import { useCompanyStore } from '@/stores/companyStore';
import { useVatReport } from '@/api/queries/vatQueries';
import { formatCurrency, formatPeriod } from '@/lib/formatters';
import { accountingColors } from '@/styles/theme';
@ -47,18 +46,25 @@ interface VATBox {
export default function Momsindberetning() {
const { company } = useCompany();
const { activeCompany } = useCompanyStore();
const [selectedPeriod, setSelectedPeriod] = useState<dayjs.Dayjs>(
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() {
<Select
value={periodType}
onChange={setPeriodType}
style={{ width: 120 }}
style={{ width: 140 }}
options={[
{ value: 'monthly', label: 'Månedlig' },
{ value: 'quarterly', label: 'Kvartalsvis' },
{ value: 'half-yearly', label: 'Halvårlig' },
{ value: 'yearly', label: 'Årlig' },
]}
/>
<DatePicker
picker={periodType === 'quarterly' ? 'quarter' : 'month'}
picker={periodType === 'quarterly' ? 'quarter' : periodType === 'yearly' ? 'year' : 'month'}
value={selectedPeriod}
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">
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>
{vatReport && (
<Tag color="green">{vatReport.transactionCount} transaktioner</Tag>

View file

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

View file

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

View file

@ -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