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:
parent
a7d76df3a7
commit
8096a19081
32 changed files with 380 additions and 163 deletions
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -512,7 +512,6 @@ public partial class PaymentMatchingService(
|
|||
.ToUpperInvariant()
|
||||
.Replace("APS", "")
|
||||
.Replace("A/S", "")
|
||||
.Replace("ApS", "")
|
||||
.Replace("IVS", "")
|
||||
.Replace("K/S", "")
|
||||
.Trim();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
Få 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() {
|
|||
Få 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue