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);
|
var response = await _httpClient.SendAsync(request, ct);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
// Log raw response for debugging
|
// Log raw response for debugging (sensitive - only at Debug level)
|
||||||
var rawJson = await response.Content.ReadAsStringAsync(ct);
|
var rawJson = await response.Content.ReadAsStringAsync(ct);
|
||||||
_logger.LogInformation("Raw session response: {RawJson}", rawJson);
|
_logger.LogDebug("Raw session response: {ResponseLength} chars", rawJson.Length);
|
||||||
|
|
||||||
var result = System.Text.Json.JsonSerializer.Deserialize<SessionApiResponse>(rawJson, _jsonOptions);
|
var result = System.Text.Json.JsonSerializer.Deserialize<SessionApiResponse>(rawJson, _jsonOptions);
|
||||||
var sessionId = result!.SessionId;
|
var sessionId = result!.SessionId;
|
||||||
|
|
@ -225,7 +225,7 @@ public class EnableBankingClient : IEnableBankingClient, IDisposable
|
||||||
var response = await _httpClient.SendAsync(request, ct);
|
var response = await _httpClient.SendAsync(request, ct);
|
||||||
|
|
||||||
var rawJson = await response.Content.ReadAsStringAsync(ct);
|
var rawJson = await response.Content.ReadAsStringAsync(ct);
|
||||||
_logger.LogInformation("Transactions API response ({Status}): {RawJson}", response.StatusCode, rawJson);
|
_logger.LogDebug("Transactions API response ({Status}): {ResponseLength} chars", response.StatusCode, rawJson.Length);
|
||||||
|
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -225,21 +225,32 @@ public class VoidInvoiceCommandHandler
|
||||||
// CREDIT NOTE COMMAND HANDLERS
|
// CREDIT NOTE COMMAND HANDLERS
|
||||||
// =====================================================
|
// =====================================================
|
||||||
|
|
||||||
public class CreateCreditNoteCommandHandler
|
/// <summary>
|
||||||
|
/// Command handler for creating credit notes.
|
||||||
|
/// Auto-assigns sequential credit note numbers (Momsloven §52 - sequential numbering required).
|
||||||
|
/// </summary>
|
||||||
|
public class CreateCreditNoteCommandHandler(
|
||||||
|
IInvoiceNumberService invoiceNumberService)
|
||||||
: CommandHandler<InvoiceAggregate, InvoiceId, CreateCreditNoteCommand>
|
: CommandHandler<InvoiceAggregate, InvoiceId, CreateCreditNoteCommand>
|
||||||
{
|
{
|
||||||
public override Task ExecuteAsync(
|
public override async Task ExecuteAsync(
|
||||||
InvoiceAggregate aggregate,
|
InvoiceAggregate aggregate,
|
||||||
CreateCreditNoteCommand command,
|
CreateCreditNoteCommand command,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// Auto-assign credit note number (Momsloven §52 requires unbroken sequential numbering)
|
||||||
|
var creditNoteNumber = await invoiceNumberService.GetNextCreditNoteNumberAsync(
|
||||||
|
command.CompanyId,
|
||||||
|
command.CreditNoteDate.Year,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
aggregate.CreateCreditNote(
|
aggregate.CreateCreditNote(
|
||||||
command.CompanyId,
|
command.CompanyId,
|
||||||
command.FiscalYearId,
|
command.FiscalYearId,
|
||||||
command.CustomerId,
|
command.CustomerId,
|
||||||
command.CustomerName,
|
command.CustomerName,
|
||||||
command.CustomerNumber,
|
command.CustomerNumber,
|
||||||
command.CreditNoteNumber,
|
creditNoteNumber,
|
||||||
command.CreditNoteDate,
|
command.CreditNoteDate,
|
||||||
command.Currency,
|
command.Currency,
|
||||||
command.VatCode,
|
command.VatCode,
|
||||||
|
|
@ -249,8 +260,6 @@ public class CreateCreditNoteCommandHandler
|
||||||
command.OriginalInvoiceId,
|
command.OriginalInvoiceId,
|
||||||
command.OriginalInvoiceNumber,
|
command.OriginalInvoiceNumber,
|
||||||
command.CreditReason);
|
command.CreditReason);
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -185,13 +185,15 @@ public class AttachmentController(
|
||||||
[HttpGet("{*storagePath}")]
|
[HttpGet("{*storagePath}")]
|
||||||
public async Task<IActionResult> Download(string storagePath, CancellationToken cancellationToken)
|
public async Task<IActionResult> Download(string storagePath, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Validate path to prevent directory traversal attacks
|
// Validate path to prevent directory traversal attacks using canonical path resolution
|
||||||
if (string.IsNullOrWhiteSpace(storagePath) ||
|
if (string.IsNullOrWhiteSpace(storagePath))
|
||||||
storagePath.Contains("..") ||
|
{
|
||||||
storagePath.Contains("~") ||
|
return BadRequest(new { error = "INVALID_PATH", message = "Invalid storage path" });
|
||||||
Path.IsPathRooted(storagePath) ||
|
}
|
||||||
storagePath.StartsWith("/") ||
|
|
||||||
storagePath.StartsWith("\\"))
|
var basePath = fileStorage.GetBasePath();
|
||||||
|
var fullPath = Path.GetFullPath(Path.Combine(basePath, storagePath));
|
||||||
|
if (!fullPath.StartsWith(Path.GetFullPath(basePath), StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
logger.LogWarning("Attempted path traversal attack with path: {StoragePath}", storagePath);
|
logger.LogWarning("Attempted path traversal attack with path: {StoragePath}", storagePath);
|
||||||
return BadRequest(new { error = "INVALID_PATH", message = "Invalid storage path" });
|
return BadRequest(new { error = "INVALID_PATH", message = "Invalid storage path" });
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Books.Api.Authorization;
|
||||||
using Books.Api.Banking;
|
using Books.Api.Banking;
|
||||||
using Books.Api.Commands.BankConnections;
|
using Books.Api.Commands.BankConnections;
|
||||||
using Books.Api.Domain.BankConnections;
|
using Books.Api.Domain.BankConnections;
|
||||||
|
using Books.Api.EventFlow.Repositories;
|
||||||
using EventFlow;
|
using EventFlow;
|
||||||
using EventFlow.Aggregates.ExecutionResults;
|
using EventFlow.Aggregates.ExecutionResults;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
@ -17,17 +22,23 @@ public class BankingController : ControllerBase
|
||||||
private readonly IEnableBankingClient _bankingClient;
|
private readonly IEnableBankingClient _bankingClient;
|
||||||
private readonly ILogger<BankingController> _logger;
|
private readonly ILogger<BankingController> _logger;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly IBankConnectionRepository _bankConnectionRepository;
|
||||||
|
private readonly ICompanyAccessService _companyAccess;
|
||||||
|
|
||||||
public BankingController(
|
public BankingController(
|
||||||
ICommandBus commandBus,
|
ICommandBus commandBus,
|
||||||
IEnableBankingClient bankingClient,
|
IEnableBankingClient bankingClient,
|
||||||
ILogger<BankingController> logger,
|
ILogger<BankingController> logger,
|
||||||
IConfiguration configuration)
|
IConfiguration configuration,
|
||||||
|
IBankConnectionRepository bankConnectionRepository,
|
||||||
|
ICompanyAccessService companyAccess)
|
||||||
{
|
{
|
||||||
_commandBus = commandBus;
|
_commandBus = commandBus;
|
||||||
_bankingClient = bankingClient;
|
_bankingClient = bankingClient;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
|
_bankConnectionRepository = bankConnectionRepository;
|
||||||
|
_companyAccess = companyAccess;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -60,12 +71,30 @@ public class BankingController : ControllerBase
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// TODO: Add proper CSRF/state validation. Currently the state parameter
|
// Validate HMAC-signed state token to prevent CSRF attacks
|
||||||
// is used as the connection ID, but it should also include a CSRF token
|
var connectionId = ValidateStateToken(state);
|
||||||
// that is validated against the user session to prevent cross-site request
|
if (connectionId == null)
|
||||||
// forgery attacks on the OAuth callback.
|
{
|
||||||
// State contains the connection ID (set during StartBankConnection)
|
_logger.LogWarning("Invalid or tampered state token in bank callback");
|
||||||
var connectionId = state;
|
return Redirect($"{redirectUrl}&error=invalid_state");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the user has access to the company that owns this bank connection
|
||||||
|
var bankConnection = await _bankConnectionRepository.GetByIdAsync(connectionId, ct);
|
||||||
|
if (bankConnection == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Bank connection {ConnectionId} not found", connectionId);
|
||||||
|
return Redirect($"{redirectUrl}&error=connection_not_found");
|
||||||
|
}
|
||||||
|
|
||||||
|
var canWrite = await _companyAccess.CanWriteAsync(bankConnection.CompanyId, ct);
|
||||||
|
if (!canWrite)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"User does not have write access to company {CompanyId} for bank connection {ConnectionId}",
|
||||||
|
bankConnection.CompanyId, connectionId);
|
||||||
|
return Redirect($"{redirectUrl}&error=access_denied");
|
||||||
|
}
|
||||||
|
|
||||||
// Get PSU headers from HttpContext (required by Enable Banking API)
|
// Get PSU headers from HttpContext (required by Enable Banking API)
|
||||||
var psuIpAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
var psuIpAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
|
@ -108,4 +137,52 @@ public class BankingController : ControllerBase
|
||||||
return Redirect($"{redirectUrl}&error=internal_error");
|
return Redirect($"{redirectUrl}&error=internal_error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates an HMAC-signed state token containing the connection ID.
|
||||||
|
/// Used during StartBankConnection to create a CSRF-safe state parameter.
|
||||||
|
/// </summary>
|
||||||
|
public static string GenerateStateToken(string connectionId, string secret)
|
||||||
|
{
|
||||||
|
var signature = ComputeHmac(connectionId, secret);
|
||||||
|
return $"{connectionId}.{signature}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates an HMAC-signed state token and extracts the connection ID.
|
||||||
|
/// Returns null if the token is invalid or tampered with.
|
||||||
|
/// </summary>
|
||||||
|
private string? ValidateStateToken(string state)
|
||||||
|
{
|
||||||
|
var secret = _configuration["Banking:StateSecret"] ?? _configuration["Jwt:Key"] ?? "";
|
||||||
|
|
||||||
|
// Support legacy format (plain connection ID without signature) during transition
|
||||||
|
if (!state.Contains('.'))
|
||||||
|
{
|
||||||
|
// Legacy format: treat as plain connection ID but log a warning
|
||||||
|
_logger.LogWarning("Bank callback received legacy state format without HMAC signature");
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dotIndex = state.LastIndexOf('.');
|
||||||
|
var connectionId = state[..dotIndex];
|
||||||
|
var providedSignature = state[(dotIndex + 1)..];
|
||||||
|
|
||||||
|
var expectedSignature = ComputeHmac(connectionId, secret);
|
||||||
|
if (!CryptographicOperations.FixedTimeEquals(
|
||||||
|
Encoding.UTF8.GetBytes(expectedSignature),
|
||||||
|
Encoding.UTF8.GetBytes(providedSignature)))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return connectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeHmac(string data, string secret)
|
||||||
|
{
|
||||||
|
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
||||||
|
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(data));
|
||||||
|
return Convert.ToBase64String(hash);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@ using EventFlow.Aggregates;
|
||||||
|
|
||||||
namespace Books.Api.Domain.Companies;
|
namespace Books.Api.Domain.Companies;
|
||||||
|
|
||||||
public class CompanyAggregate(CompanyId id) : AggregateRoot<CompanyAggregate, CompanyId>(id)
|
public class CompanyAggregate(CompanyId id) : AggregateRoot<CompanyAggregate, CompanyId>(id),
|
||||||
|
IEmit<CompanyCreatedEvent>,
|
||||||
|
IEmit<CompanyUpdatedEvent>,
|
||||||
|
IEmit<CompanyBankDetailsUpdatedEvent>
|
||||||
{
|
{
|
||||||
private bool _isCreated;
|
private bool _isCreated;
|
||||||
|
|
||||||
|
|
@ -11,6 +14,8 @@ public class CompanyAggregate(CompanyId id) : AggregateRoot<CompanyAggregate, Co
|
||||||
|
|
||||||
public void Apply(CompanyUpdatedEvent e) { }
|
public void Apply(CompanyUpdatedEvent e) { }
|
||||||
|
|
||||||
|
public void Apply(CompanyBankDetailsUpdatedEvent e) { }
|
||||||
|
|
||||||
public void Create(
|
public void Create(
|
||||||
string name,
|
string name,
|
||||||
string? cvr,
|
string? cvr,
|
||||||
|
|
|
||||||
|
|
@ -187,5 +187,10 @@ public class CustomerAggregate(CustomerId id) : AggregateRoot<CustomerAggregate,
|
||||||
throw new DomainException("CUSTOMER_NOT_FOUND",
|
throw new DomainException("CUSTOMER_NOT_FOUND",
|
||||||
"Customer does not exist",
|
"Customer does not exist",
|
||||||
"Kunden findes ikke");
|
"Kunden findes ikke");
|
||||||
|
|
||||||
|
if (!_isActive)
|
||||||
|
throw new DomainException("CUSTOMER_INACTIVE",
|
||||||
|
"Cannot modify an inactive customer. Reactivate first.",
|
||||||
|
"Kan ikke ændre en inaktiv kunde. Genaktiver først.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,9 @@ public class InvoiceAggregate(InvoiceId id) : AggregateRoot<InvoiceAggregate, In
|
||||||
if (unitPrice < 0)
|
if (unitPrice < 0)
|
||||||
throw new DomainException("INVALID_UNIT_PRICE", "Unit price cannot be negative", "Stykpris kan ikke være negativ");
|
throw new DomainException("INVALID_UNIT_PRICE", "Unit price cannot be negative", "Stykpris kan ikke være negativ");
|
||||||
|
|
||||||
|
if (discountPercent < 0 || discountPercent > 100)
|
||||||
|
throw new DomainException("INVALID_DISCOUNT", "Discount must be between 0% and 100%", "Rabat skal være mellem 0% og 100%");
|
||||||
|
|
||||||
var lineNumber = _lines.Count > 0 ? _lines.Max(l => l.LineNumber) + 1 : 1;
|
var lineNumber = _lines.Count > 0 ? _lines.Max(l => l.LineNumber) + 1 : 1;
|
||||||
|
|
||||||
Emit(new InvoiceLineAddedEvent(
|
Emit(new InvoiceLineAddedEvent(
|
||||||
|
|
@ -231,6 +234,9 @@ public class InvoiceAggregate(InvoiceId id) : AggregateRoot<InvoiceAggregate, In
|
||||||
if (!_lines.Any(l => l.LineNumber == lineNumber))
|
if (!_lines.Any(l => l.LineNumber == lineNumber))
|
||||||
throw new DomainException("LINE_NOT_FOUND", $"Line {lineNumber} not found", $"Linje {lineNumber} findes ikke");
|
throw new DomainException("LINE_NOT_FOUND", $"Line {lineNumber} not found", $"Linje {lineNumber} findes ikke");
|
||||||
|
|
||||||
|
if (discountPercent < 0 || discountPercent > 100)
|
||||||
|
throw new DomainException("INVALID_DISCOUNT", "Discount must be between 0% and 100%", "Rabat skal være mellem 0% og 100%");
|
||||||
|
|
||||||
Emit(new InvoiceLineUpdatedEvent(
|
Emit(new InvoiceLineUpdatedEvent(
|
||||||
lineNumber,
|
lineNumber,
|
||||||
description.Trim(),
|
description.Trim(),
|
||||||
|
|
|
||||||
|
|
@ -147,34 +147,59 @@ public class VatCalculationService : IVatCalculationService
|
||||||
vatAmount = Math.Round(amount * rate, 2, MidpointRounding.AwayFromZero);
|
vatAmount = Math.Round(amount * rate, 2, MidpointRounding.AwayFromZero);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply deductibility percentage (Momsloven §42 stk. 2 for REP)
|
||||||
|
var deductiblePercent = VatCodes.GetDeductiblePercent(line.VatCode);
|
||||||
|
var deductibleVatAmount = Math.Round(vatAmount * deductiblePercent, 2, MidpointRounding.AwayFromZero);
|
||||||
|
|
||||||
// Determine if this is input or output VAT
|
// Determine if this is input or output VAT
|
||||||
var isInputVat = VatCodes.IsInputVat(line.VatCode);
|
var isInputVat = VatCodes.IsInputVat(line.VatCode);
|
||||||
var vatAccountId = isInputVat ? inputVatAccountId : outputVatAccountId;
|
var vatAccountId = isInputVat ? inputVatAccountId : outputVatAccountId;
|
||||||
|
var isReverseCharge = line.VatCode is VatCodes.IEUV or VatCodes.IEUY or VatCodes.IVY;
|
||||||
|
|
||||||
// Create VAT posting line
|
if (isReverseCharge)
|
||||||
// For sales (output VAT): we credit the VAT account
|
{
|
||||||
// For purchases (input VAT): we debit the VAT account
|
// 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";
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
// Standard VAT posting line (using deductible amount for REP)
|
||||||
var vatLine = new VatPostingLine
|
var vatLine = new VatPostingLine
|
||||||
{
|
{
|
||||||
AccountId = vatAccountId,
|
AccountId = vatAccountId,
|
||||||
DebitAmount = isInputVat && isDebit ? vatAmount : (isInputVat ? 0 : 0),
|
DebitAmount = isDebit ? deductibleVatAmount : 0,
|
||||||
CreditAmount = !isInputVat && !isDebit ? vatAmount : (!isInputVat && isDebit ? vatAmount : 0),
|
CreditAmount = !isDebit ? deductibleVatAmount : 0,
|
||||||
Description = $"Moms {line.VatCode} ({rate * 100:0}%)",
|
Description = $"Moms {line.VatCode} ({rate * 100:0}%)",
|
||||||
VatCode = line.VatCode,
|
VatCode = line.VatCode,
|
||||||
SourceLineNumber = line.LineNumber
|
SourceLineNumber = line.LineNumber
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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
|
|
||||||
{
|
|
||||||
DebitAmount = isDebit ? vatAmount : 0,
|
|
||||||
CreditAmount = !isDebit ? vatAmount : 0
|
|
||||||
};
|
|
||||||
|
|
||||||
vatLines.Add(vatLine);
|
vatLines.Add(vatLine);
|
||||||
|
}
|
||||||
|
|
||||||
// Accumulate VAT summary
|
// Accumulate VAT summary
|
||||||
if (!vatByCode.TryGetValue(line.VatCode, out var summary))
|
if (!vatByCode.TryGetValue(line.VatCode, out var summary))
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,9 @@ public class OrderAggregate(OrderId id) : AggregateRoot<OrderAggregate, OrderId>
|
||||||
if (unitPrice < 0)
|
if (unitPrice < 0)
|
||||||
throw new DomainException("INVALID_UNIT_PRICE", "Unit price cannot be negative", "Stykpris kan ikke være negativ");
|
throw new DomainException("INVALID_UNIT_PRICE", "Unit price cannot be negative", "Stykpris kan ikke være negativ");
|
||||||
|
|
||||||
|
if (discountPercent < 0 || discountPercent > 100)
|
||||||
|
throw new DomainException("INVALID_DISCOUNT", "Discount must be between 0% and 100%", "Rabat skal være mellem 0% og 100%");
|
||||||
|
|
||||||
var lineNumber = _lines.Count > 0 ? _lines.Max(l => l.LineNumber) + 1 : 1;
|
var lineNumber = _lines.Count > 0 ? _lines.Max(l => l.LineNumber) + 1 : 1;
|
||||||
|
|
||||||
Emit(new OrderLineAddedEvent(
|
Emit(new OrderLineAddedEvent(
|
||||||
|
|
@ -267,6 +270,9 @@ public class OrderAggregate(OrderId id) : AggregateRoot<OrderAggregate, OrderId>
|
||||||
if (line.IsInvoiced)
|
if (line.IsInvoiced)
|
||||||
throw new DomainException("LINE_ALREADY_INVOICED", $"Line {lineNumber} has already been invoiced", $"Linje {lineNumber} er allerede faktureret");
|
throw new DomainException("LINE_ALREADY_INVOICED", $"Line {lineNumber} has already been invoiced", $"Linje {lineNumber} er allerede faktureret");
|
||||||
|
|
||||||
|
if (discountPercent < 0 || discountPercent > 100)
|
||||||
|
throw new DomainException("INVALID_DISCOUNT", "Discount must be between 0% and 100%", "Rabat skal være mellem 0% og 100%");
|
||||||
|
|
||||||
Emit(new OrderLineUpdatedEvent(
|
Emit(new OrderLineUpdatedEvent(
|
||||||
lineNumber,
|
lineNumber,
|
||||||
description.Trim(),
|
description.Trim(),
|
||||||
|
|
|
||||||
|
|
@ -130,5 +130,10 @@ public class ProductAggregate(ProductId id) : AggregateRoot<ProductAggregate, Pr
|
||||||
throw new DomainException("PRODUCT_NOT_FOUND",
|
throw new DomainException("PRODUCT_NOT_FOUND",
|
||||||
"Product does not exist",
|
"Product does not exist",
|
||||||
"Produktet findes ikke");
|
"Produktet findes ikke");
|
||||||
|
|
||||||
|
if (!_isActive)
|
||||||
|
throw new DomainException("PRODUCT_INACTIVE",
|
||||||
|
"Cannot modify an inactive product. Reactivate it first.",
|
||||||
|
"Kan ikke ændre et inaktivt produkt. Genaktiver det først.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ public static class VatCodes
|
||||||
I25 => 0.25m,
|
I25 => 0.25m,
|
||||||
IEUV => 0.25m, // Reverse charge - calculated but offset
|
IEUV => 0.25m, // Reverse charge - calculated but offset
|
||||||
IEUY => 0.25m, // Reverse charge - calculated but offset
|
IEUY => 0.25m, // Reverse charge - calculated but offset
|
||||||
|
IVY => 0.25m, // Reverse charge for imported services (Momsloven §46)
|
||||||
REP => 0.25m, // 25% rate, but only 25% deductible
|
REP => 0.25m, // 25% rate, but only 25% deductible
|
||||||
_ => 0m
|
_ => 0m
|
||||||
};
|
};
|
||||||
|
|
@ -43,6 +44,7 @@ public static class VatCodes
|
||||||
I25 => 1.0m, // 100% deductible
|
I25 => 1.0m, // 100% deductible
|
||||||
IEUV => 1.0m, // 100% deductible (reverse charge)
|
IEUV => 1.0m, // 100% deductible (reverse charge)
|
||||||
IEUY => 1.0m, // 100% deductible (reverse charge)
|
IEUY => 1.0m, // 100% deductible (reverse charge)
|
||||||
|
IVY => 1.0m, // 100% deductible (reverse charge, Momsloven §46)
|
||||||
REP => 0.25m, // Only 25% deductible
|
REP => 0.25m, // Only 25% deductible
|
||||||
_ => 0m
|
_ => 0m
|
||||||
};
|
};
|
||||||
|
|
@ -80,7 +82,7 @@ public static class VatCodes
|
||||||
new(IEUV, "EU-køb varer", "EU Purchase Goods", 0.25m),
|
new(IEUV, "EU-køb varer", "EU Purchase Goods", 0.25m),
|
||||||
new(IEUY, "EU-køb ydelser", "EU Purchase Services", 0.25m),
|
new(IEUY, "EU-køb ydelser", "EU Purchase Services", 0.25m),
|
||||||
new(IVV, "Import varer", "Import Goods", 0m),
|
new(IVV, "Import varer", "Import Goods", 0m),
|
||||||
new(IVY, "Import ydelser", "Import Services", 0m),
|
new(IVY, "Import ydelser", "Import Services", 0.25m),
|
||||||
new(REP, "Repræsentation", "Entertainment", 0.25m),
|
new(REP, "Repræsentation", "Entertainment", 0.25m),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,11 @@ public interface IFileStorageService
|
||||||
/// <param name="expiresIn">URL expiration time</param>
|
/// <param name="expiresIn">URL expiration time</param>
|
||||||
/// <returns>Download URL</returns>
|
/// <returns>Download URL</returns>
|
||||||
string GetDownloadUrl(string storagePath, TimeSpan? expiresIn = null);
|
string GetDownloadUrl(string storagePath, TimeSpan? expiresIn = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the base storage path for canonical path validation.
|
||||||
|
/// </summary>
|
||||||
|
string GetBasePath();
|
||||||
}
|
}
|
||||||
|
|
||||||
public record StorageResult
|
public record StorageResult
|
||||||
|
|
|
||||||
|
|
@ -95,11 +95,15 @@ public class LocalFileStorageService : IFileStorageService
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string GetBasePath() => _basePath;
|
||||||
|
|
||||||
public string GetDownloadUrl(string storagePath, TimeSpan? expiresIn = null)
|
public string GetDownloadUrl(string storagePath, TimeSpan? expiresIn = null)
|
||||||
{
|
{
|
||||||
// For local storage, return API endpoint URL
|
// For local storage, return API endpoint URL
|
||||||
// The actual download is handled by a controller
|
// The actual download is handled by a controller
|
||||||
return $"{_baseUrl}/{Uri.EscapeDataString(storagePath)}";
|
// Encode each path segment individually to avoid double-encoding path separators
|
||||||
|
var encodedPath = string.Join("/", storagePath.Split('/').Select(Uri.EscapeDataString));
|
||||||
|
return $"{_baseUrl}/{encodedPath}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string SanitizeFileName(string fileName)
|
private static string SanitizeFileName(string fileName)
|
||||||
|
|
|
||||||
|
|
@ -512,7 +512,6 @@ public partial class PaymentMatchingService(
|
||||||
.ToUpperInvariant()
|
.ToUpperInvariant()
|
||||||
.Replace("APS", "")
|
.Replace("APS", "")
|
||||||
.Replace("A/S", "")
|
.Replace("A/S", "")
|
||||||
.Replace("ApS", "")
|
|
||||||
.Replace("IVS", "")
|
.Replace("IVS", "")
|
||||||
.Replace("K/S", "")
|
.Replace("K/S", "")
|
||||||
.Trim();
|
.Trim();
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ public class VatReportService(
|
||||||
// as different chart-of-accounts templates may use different numbers.
|
// as different chart-of-accounts templates may use different numbers.
|
||||||
private const string InputVatAccountNumber = "5610"; // Købsmoms (indgående moms)
|
private const string InputVatAccountNumber = "5610"; // Købsmoms (indgående moms)
|
||||||
private const string OutputVatAccountNumber = "5611"; // Salgsmoms (udgående moms)
|
private const string OutputVatAccountNumber = "5611"; // Salgsmoms (udgående moms)
|
||||||
|
private const string EuAcquisitionVatAccountNumber = "5620"; // EU erhvervelsesmoms
|
||||||
|
|
||||||
public async Task<VatReportDto> GenerateReportAsync(
|
public async Task<VatReportDto> GenerateReportAsync(
|
||||||
string companyId,
|
string companyId,
|
||||||
|
|
@ -46,6 +47,8 @@ public class VatReportService(
|
||||||
companyId, InputVatAccountNumber, ct);
|
companyId, InputVatAccountNumber, ct);
|
||||||
var outputVatAccount = await accountRepository.GetByCompanyAndNumberAsync(
|
var outputVatAccount = await accountRepository.GetByCompanyAndNumberAsync(
|
||||||
companyId, OutputVatAccountNumber, ct);
|
companyId, OutputVatAccountNumber, ct);
|
||||||
|
var euAcquisitionVatAccount = await accountRepository.GetByCompanyAndNumberAsync(
|
||||||
|
companyId, EuAcquisitionVatAccountNumber, ct);
|
||||||
|
|
||||||
var report = new VatReportDto
|
var report = new VatReportDto
|
||||||
{
|
{
|
||||||
|
|
@ -53,8 +56,8 @@ public class VatReportService(
|
||||||
PeriodEnd = periodEnd
|
PeriodEnd = periodEnd
|
||||||
};
|
};
|
||||||
|
|
||||||
// If neither VAT account exists, return empty report
|
// If no VAT accounts exist, return empty report
|
||||||
if (inputVatAccount == null && outputVatAccount == null)
|
if (inputVatAccount == null && outputVatAccount == null && euAcquisitionVatAccount == null)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"No VAT accounts found for company {CompanyId}. Returning empty report.",
|
"No VAT accounts found for company {CompanyId}. Returning empty report.",
|
||||||
|
|
@ -75,6 +78,11 @@ public class VatReportService(
|
||||||
accountIds.Add(outputGuid);
|
accountIds.Add(outputGuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (euAcquisitionVatAccount != null && TryParseAccountGuid(euAcquisitionVatAccount.Id, out var euAcquisitionGuid))
|
||||||
|
{
|
||||||
|
accountIds.Add(euAcquisitionGuid);
|
||||||
|
}
|
||||||
|
|
||||||
if (accountIds.Count == 0)
|
if (accountIds.Count == 0)
|
||||||
{
|
{
|
||||||
logger.LogWarning("No valid VAT account GUIDs found for company {CompanyId}", companyId);
|
logger.LogWarning("No valid VAT account GUIDs found for company {CompanyId}", companyId);
|
||||||
|
|
@ -123,40 +131,78 @@ public class VatReportService(
|
||||||
"Output VAT (5611): TotalDebits={Debits}, TotalCredits={Credits}, NetChange={Net}",
|
"Output VAT (5611): TotalDebits={Debits}, TotalCredits={Credits}, NetChange={Net}",
|
||||||
balance.TotalDebits, balance.TotalCredits, balance.NetChange);
|
balance.TotalDebits, balance.TotalCredits, balance.NetChange);
|
||||||
}
|
}
|
||||||
|
else if (euAcquisitionVatAccount != null &&
|
||||||
|
TryParseAccountGuid(euAcquisitionVatAccount.Id, out var checkEuGuid) &&
|
||||||
|
balance.AccountId == checkEuGuid)
|
||||||
|
{
|
||||||
|
// EU Acquisition VAT (5620): Debits are reverse charge VAT
|
||||||
|
// This covers both EU goods (IEUV) and EU/world services (IEUY/IVY)
|
||||||
|
// Box C + Box D = total EU acquisition VAT debits
|
||||||
|
// Without VAT code breakdown at ledger level, we report the total
|
||||||
|
// in Box C (EU goods). When VAT code-level data becomes available,
|
||||||
|
// split IEUV -> Box C and IEUY/IVY -> Box D.
|
||||||
|
report.BoxC = balance.TotalDebits;
|
||||||
|
report.TransactionCount += balance.EntryCount;
|
||||||
|
|
||||||
|
// Basis3 = base amount for EU acquisition (reverse charge VAT / 0.25)
|
||||||
|
if (balance.TotalDebits > 0)
|
||||||
|
{
|
||||||
|
report.Basis3 = Math.Round(balance.TotalDebits / 0.25m, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogDebug(
|
||||||
|
"EU Acquisition VAT (5620): TotalDebits={Debits}, TotalCredits={Credits}, NetChange={Net}",
|
||||||
|
balance.TotalDebits, balance.TotalCredits, balance.NetChange);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate summary totals
|
// Calculate summary totals
|
||||||
// Box A = Salgsmoms (domestic output VAT)
|
// Box A = Salgsmoms (domestic output VAT)
|
||||||
// Box B = Købsmoms (input VAT - deductible)
|
// Box B = Købsmoms (input VAT - deductible)
|
||||||
// Box C = EU-varekøb moms (not yet supported - requires VAT code breakdown)
|
// Box C = EU-varekøb moms (reverse charge on EU goods/services)
|
||||||
// Box D = Ydelseskøb moms (not yet supported - requires VAT code breakdown)
|
// Box D = Ydelseskøb moms (reverse charge on services from abroad)
|
||||||
report.TotalOutputVat = report.BoxA + report.BoxC + report.BoxD;
|
report.TotalOutputVat = report.BoxA + report.BoxC + report.BoxD;
|
||||||
report.TotalInputVat = report.BoxB;
|
report.TotalInputVat = report.BoxB;
|
||||||
report.NetVat = report.TotalOutputVat - report.TotalInputVat;
|
report.NetVat = report.TotalOutputVat - report.TotalInputVat;
|
||||||
|
|
||||||
// Basis1 (Felt 1): Net domestic turnover with VAT
|
// Basis1 (Felt 1): Net domestic turnover with VAT
|
||||||
// TODO: Query actual net turnover from transactions with output VAT codes (U25)
|
// Query revenue account totals (accounts 1000-1999) for actual turnover basis
|
||||||
// instead of back-calculating from VAT amount, which is inaccurate when
|
// instead of back-calculating from VAT which is inaccurate with mixed rates
|
||||||
// mixed VAT rates or partial deductions are involved.
|
var revenueAccounts = await accountRepository.GetByCompanyIdAsync(companyId, ct);
|
||||||
// Ideally: query revenue account balances filtered by VAT code U25.
|
var revenueAccountIds = new List<Guid>();
|
||||||
// For now, back-calculate from output VAT assuming standard 25% rate
|
foreach (var acc in revenueAccounts)
|
||||||
if (report.BoxA > 0)
|
{
|
||||||
|
if (int.TryParse(acc.AccountNumber, out var num) && num >= 1000 && num <= 1999 &&
|
||||||
|
TryParseAccountGuid(acc.Id, out var revGuid))
|
||||||
|
{
|
||||||
|
revenueAccountIds.Add(revGuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (revenueAccountIds.Count > 0)
|
||||||
|
{
|
||||||
|
var revenueQuery = new EntriesQuery
|
||||||
|
{
|
||||||
|
AccountIds = revenueAccountIds,
|
||||||
|
From = new DateTimeOffset(periodStart.ToDateTime(TimeOnly.MinValue)),
|
||||||
|
To = new DateTimeOffset(periodEnd.ToDateTime(TimeOnly.MaxValue)),
|
||||||
|
Aggregate = true
|
||||||
|
};
|
||||||
|
var revenueResult = await ledgerService.QueryEntriesAsync(revenueQuery, ct);
|
||||||
|
if (revenueResult.Aggregates != null)
|
||||||
|
{
|
||||||
|
// Revenue accounts have credits for income; sum the credits
|
||||||
|
report.Basis1 = revenueResult.Aggregates.Sum(a => a.TotalCredits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: if no revenue data found, back-calculate from output VAT
|
||||||
|
if (report.Basis1 == 0 && report.BoxA > 0)
|
||||||
{
|
{
|
||||||
report.Basis1 = Math.Round(report.BoxA / 0.25m, 2);
|
report.Basis1 = Math.Round(report.BoxA / 0.25m, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Box C (EU-varekøb moms) - Requires VAT code breakdown from transactions.
|
|
||||||
// Query transactions with VAT code IEUV to compute reverse-charge VAT on EU goods.
|
|
||||||
// report.BoxC = sum of VAT calculated on IEUV transactions.
|
|
||||||
// report.Basis3 = net purchase amount for IEUV transactions.
|
|
||||||
|
|
||||||
// TODO: Box D (Ydelseskøb moms) - Requires VAT code breakdown from transactions.
|
|
||||||
// Query transactions with VAT codes IEUY, IVV, IVY to compute reverse-charge VAT
|
|
||||||
// on services purchased from abroad.
|
|
||||||
// report.BoxD = sum of VAT calculated on IEUY/IVV/IVY transactions.
|
|
||||||
// report.Basis4 = net purchase amount for IEUY/IVV/IVY transactions.
|
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"VAT report generated for company {CompanyId}: OutputVAT={OutputVat}, InputVAT={InputVat}, NetVAT={NetVat}",
|
"VAT report generated for company {CompanyId}: OutputVAT={OutputVat}, InputVAT={InputVat}, NetVAT={NetVat}",
|
||||||
companyId, report.TotalOutputVat, report.TotalInputVat, report.NetVat);
|
companyId, report.TotalOutputVat, report.TotalInputVat, report.NetVat);
|
||||||
|
|
|
||||||
|
|
@ -212,14 +212,16 @@ public class SaftExportService(
|
||||||
accountBalances.TryGetValue(guid ?? Guid.Empty, out var periodBalance);
|
accountBalances.TryGetValue(guid ?? Guid.Empty, out var periodBalance);
|
||||||
openingBalances.TryGetValue(guid ?? Guid.Empty, out var openingBalance);
|
openingBalances.TryGetValue(guid ?? Guid.Empty, out var openingBalance);
|
||||||
|
|
||||||
// Opening balance = net balance from all transactions before period start
|
// Compute net balance: debits - credits
|
||||||
// Net balance is calculated as: Debits - Credits for Asset/Expense, Credits - Debits for Liability/Equity/Income
|
// SAF-T expects a single net balance reported as either DebitBalance or CreditBalance
|
||||||
var openingDebit = openingBalance.TotalDebits;
|
var openingNet = openingBalance.TotalDebits - openingBalance.TotalCredits;
|
||||||
var openingCredit = openingBalance.TotalCredits;
|
var openingDebit = openingNet >= 0 ? openingNet : 0m;
|
||||||
|
var openingCredit = openingNet < 0 ? Math.Abs(openingNet) : 0m;
|
||||||
|
|
||||||
// Closing balance = Opening balance + Period movements
|
// Closing balance = Opening net + Period net
|
||||||
var closingDebit = openingDebit + periodBalance.TotalDebits;
|
var closingNet = openingNet + (periodBalance.TotalDebits - periodBalance.TotalCredits);
|
||||||
var closingCredit = openingCredit + periodBalance.TotalCredits;
|
var closingDebit = closingNet >= 0 ? closingNet : 0m;
|
||||||
|
var closingCredit = closingNet < 0 ? Math.Abs(closingNet) : 0m;
|
||||||
|
|
||||||
return new SaftAccount(
|
return new SaftAccount(
|
||||||
acc.AccountNumber,
|
acc.AccountNumber,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ namespace Books.Api.Saft.Services;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SaftXmlBuilder
|
public class SaftXmlBuilder
|
||||||
{
|
{
|
||||||
private const string SaftNamespace = "urn:StandardAuditFile-Taxation-Financial:DK";
|
private const string SaftNamespace = "urn:OECD:StandardAuditFile-Taxation/2.00";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a SAF-T XML document from the provided data.
|
/// Builds a SAF-T XML document from the provided data.
|
||||||
|
|
@ -166,7 +166,7 @@ public class SaftXmlBuilder
|
||||||
WriteTaxCodeDetails(writer, "IEUV", "EU-erhvervelse varer (reverse charge)", 25.00m);
|
WriteTaxCodeDetails(writer, "IEUV", "EU-erhvervelse varer (reverse charge)", 25.00m);
|
||||||
WriteTaxCodeDetails(writer, "IEUY", "EU-erhvervelse ydelser (reverse charge)", 25.00m);
|
WriteTaxCodeDetails(writer, "IEUY", "EU-erhvervelse ydelser (reverse charge)", 25.00m);
|
||||||
WriteTaxCodeDetails(writer, "IVV", "Import varer fra verden", 0.00m);
|
WriteTaxCodeDetails(writer, "IVV", "Import varer fra verden", 0.00m);
|
||||||
WriteTaxCodeDetails(writer, "IVY", "Import ydelser fra verden", 0.00m);
|
WriteTaxCodeDetails(writer, "IVY", "Import ydelser fra verden (reverse charge)", 25.00m);
|
||||||
|
|
||||||
// Special codes
|
// Special codes
|
||||||
WriteTaxCodeDetails(writer, "REP", "Repræsentation (25% fradrag)", 25.00m);
|
WriteTaxCodeDetails(writer, "REP", "Repræsentation (25% fradrag)", 25.00m);
|
||||||
|
|
@ -347,11 +347,19 @@ public class SaftXmlBuilder
|
||||||
if (!string.IsNullOrEmpty(line.Description))
|
if (!string.IsNullOrEmpty(line.Description))
|
||||||
writer.WriteElementString("Description", line.Description);
|
writer.WriteElementString("Description", line.Description);
|
||||||
|
|
||||||
if (line.DebitAmount.HasValue && line.DebitAmount.Value != 0)
|
// SAF-T schema requires at least one of DebitAmount or CreditAmount
|
||||||
writer.WriteElementString("DebitAmount", FormatDecimal(line.DebitAmount.Value));
|
var hasDebit = line.DebitAmount.HasValue && line.DebitAmount.Value != 0;
|
||||||
|
var hasCredit = line.CreditAmount.HasValue && line.CreditAmount.Value != 0;
|
||||||
|
|
||||||
if (line.CreditAmount.HasValue && line.CreditAmount.Value != 0)
|
if (hasDebit)
|
||||||
writer.WriteElementString("CreditAmount", FormatDecimal(line.CreditAmount.Value));
|
writer.WriteElementString("DebitAmount", FormatDecimal(line.DebitAmount!.Value));
|
||||||
|
|
||||||
|
if (hasCredit)
|
||||||
|
writer.WriteElementString("CreditAmount", FormatDecimal(line.CreditAmount!.Value));
|
||||||
|
|
||||||
|
// If neither has a value, write a zero debit to satisfy schema requirement
|
||||||
|
if (!hasDebit && !hasCredit)
|
||||||
|
writer.WriteElementString("DebitAmount", FormatDecimal(0m));
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(line.CustomerID))
|
if (!string.IsNullOrEmpty(line.CustomerID))
|
||||||
writer.WriteElementString("CustomerID", line.CustomerID);
|
writer.WriteElementString("CustomerID", line.CustomerID);
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,8 @@ export async function fetchGraphQL<TData, TVariables extends Record<string, unkn
|
||||||
const data = await graphqlClient.request<TData>(query, variables, headers);
|
const data = await graphqlClient.request<TData>(query, variables, headers);
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log error for debugging
|
// Log error for debugging (dev only)
|
||||||
console.error('GraphQL Error:', error);
|
if (import.meta.env.DEV) console.error('GraphQL Error:', error);
|
||||||
|
|
||||||
// Re-throw with more context
|
// Re-throw with more context
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
DatePicker,
|
DatePicker,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { showSuccess, showError } from '@/lib/errorHandling';
|
import { showSuccess, showError } from '@/lib/errorHandling';
|
||||||
|
import { formatDate } from '@/lib/formatters';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
|
|
@ -64,13 +65,6 @@ function getStatusTag(status: BankConnection['status'], isActive: boolean) {
|
||||||
return <Tag>{status}</Tag>;
|
return <Tag>{status}</Tag>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateString: string) {
|
|
||||||
return new Date(dateString).toLocaleDateString('da-DK', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BankConnectionsTab({ companyId }: BankConnectionsTabProps) {
|
export default function BankConnectionsTab({ companyId }: BankConnectionsTabProps) {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Typography, Tooltip } from 'antd';
|
import { Typography, Tooltip } from 'antd';
|
||||||
import { ArrowUpOutlined, ArrowDownOutlined, MinusOutlined } from '@ant-design/icons';
|
import { ArrowUpOutlined, ArrowDownOutlined, MinusOutlined } from '@ant-design/icons';
|
||||||
import { formatCurrency } from '@/lib/formatters';
|
import { formatCurrency, formatNumber } from '@/lib/formatters';
|
||||||
import { accountingColors } from '@/styles/theme';
|
import { accountingColors } from '@/styles/theme';
|
||||||
import { typography, getAmountColor } from '@/styles/designTokens';
|
import { typography, getAmountColor } from '@/styles/designTokens';
|
||||||
|
|
||||||
|
|
@ -77,7 +77,7 @@ export function AmountText({
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatAmount = (): string => {
|
const formatAmount = (): string => {
|
||||||
const formatted = formatCurrency(Math.abs(amount));
|
const formatted = formatNumber(Math.abs(amount));
|
||||||
// Always show +/- prefix for non-zero amounts (accessibility: not color-only)
|
// Always show +/- prefix for non-zero amounts (accessibility: not color-only)
|
||||||
// When showSign is explicitly true, same behavior; kept for API compatibility
|
// When showSign is explicitly true, same behavior; kept for API compatibility
|
||||||
const alwaysSign = showSign;
|
const alwaysSign = showSign;
|
||||||
|
|
|
||||||
|
|
@ -138,11 +138,11 @@ export const VAT_RATES = {
|
||||||
* Standard Danish VAT codes
|
* Standard Danish VAT codes
|
||||||
*/
|
*/
|
||||||
export const VAT_CODES = {
|
export const VAT_CODES = {
|
||||||
S25: { code: 'S25', name: 'Udgående moms 25%', rate: 0.25, type: 'output' },
|
U25: { code: 'U25', name: 'Udgående moms 25%', rate: 0.25, type: 'output' },
|
||||||
K25: { code: 'K25', name: 'Indgående moms 25%', rate: 0.25, type: 'input' },
|
I25: { code: 'I25', name: 'Indgående moms 25%', rate: 0.25, type: 'input' },
|
||||||
E0: { code: 'E0', name: 'EU-varekøb 0%', rate: 0, type: 'eu' },
|
E0: { code: 'E0', name: 'EU-varekøb 0%', rate: 0, type: 'eu' },
|
||||||
U0: { code: 'U0', name: 'Eksport 0%', rate: 0, type: 'export' },
|
U0: { code: 'U0', name: 'Eksport 0%', rate: 0, type: 'export' },
|
||||||
NONE: { code: 'NONE', name: 'Ingen moms', rate: 0, type: 'none' },
|
INGEN: { code: 'INGEN', name: 'Ingen moms', rate: 0, type: 'none' },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -298,11 +298,11 @@ export function generateSimpleDoubleEntry(input: SimpleBookingInput): GeneratedT
|
||||||
|
|
||||||
// For reverse charge (EU purchases), also credit output VAT
|
// For reverse charge (EU purchases), also credit output VAT
|
||||||
if (vatConfig.reverseCharge && vatAmount > 0) {
|
if (vatConfig.reverseCharge && vatAmount > 0) {
|
||||||
const outputVatAccount = vatCode === 'EU_VARE' ? VAT_ACCOUNTS.euVAT : VAT_ACCOUNTS.outputVAT;
|
const outputVatAccount = vatCode === 'IEUV' ? VAT_ACCOUNTS.euVAT : VAT_ACCOUNTS.outputVAT;
|
||||||
lines.push({
|
lines.push({
|
||||||
accountId: `vat-output-${vatCode}`,
|
accountId: `vat-output-${vatCode}`,
|
||||||
accountNumber: outputVatAccount,
|
accountNumber: outputVatAccount,
|
||||||
accountName: vatCode === 'EU_VARE' ? 'EU-moms (erhvervelse)' : 'Udgående moms',
|
accountName: vatCode === 'IEUV' ? 'EU-moms (erhvervelse)' : 'Udgående moms',
|
||||||
description: `Moms: ${description}`,
|
description: `Moms: ${description}`,
|
||||||
debit: 0,
|
debit: 0,
|
||||||
credit: vatAmount,
|
credit: vatAmount,
|
||||||
|
|
@ -440,11 +440,11 @@ export function generateSplitDoubleEntry(input: SplitBookingInput): GeneratedTra
|
||||||
|
|
||||||
// For reverse charge, also credit output VAT
|
// For reverse charge, also credit output VAT
|
||||||
if (vatConfig.reverseCharge && lineVat > 0) {
|
if (vatConfig.reverseCharge && lineVat > 0) {
|
||||||
const outputVatAccount = splitLine.vatCode === 'EU_VARE' ? VAT_ACCOUNTS.euVAT : VAT_ACCOUNTS.outputVAT;
|
const outputVatAccount = splitLine.vatCode === 'IEUV' ? VAT_ACCOUNTS.euVAT : VAT_ACCOUNTS.outputVAT;
|
||||||
generatedLines.push({
|
generatedLines.push({
|
||||||
accountId: `vat-output-${splitLine.vatCode}`,
|
accountId: `vat-output-${splitLine.vatCode}`,
|
||||||
accountNumber: outputVatAccount,
|
accountNumber: outputVatAccount,
|
||||||
accountName: splitLine.vatCode === 'EU_VARE' ? 'EU-moms (erhvervelse)' : 'Udgående moms',
|
accountName: splitLine.vatCode === 'IEUV' ? 'EU-moms (erhvervelse)' : 'Udgående moms',
|
||||||
description: `Moms: ${description}`,
|
description: `Moms: ${description}`,
|
||||||
debit: 0,
|
debit: 0,
|
||||||
credit: lineVat,
|
credit: lineVat,
|
||||||
|
|
@ -541,17 +541,17 @@ export function getSuggestedVATCode(accountNumber: string, isExpense: boolean):
|
||||||
// Expenses typically use input VAT (K25)
|
// Expenses typically use input VAT (K25)
|
||||||
if (isExpense) {
|
if (isExpense) {
|
||||||
// Some expense types are typically VAT-exempt
|
// Some expense types are typically VAT-exempt
|
||||||
if (accountType === 'financial') return 'NONE';
|
if (accountType === 'financial') return 'INGEN';
|
||||||
if (accountType === 'personnel') return 'NONE';
|
if (accountType === 'personnel') return 'INGEN';
|
||||||
return 'K25';
|
return 'I25';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revenue typically uses output VAT (S25)
|
// Revenue typically uses output VAT (U25)
|
||||||
if (accountType === 'revenue') {
|
if (accountType === 'revenue') {
|
||||||
return 'S25';
|
return 'U25';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'NONE';
|
return 'INGEN';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -159,11 +159,6 @@ function processTransactionLine(
|
||||||
|
|
||||||
const vatCode = line.vatCode;
|
const vatCode = line.vatCode;
|
||||||
|
|
||||||
// Skip lines without VAT relevance
|
|
||||||
if (vatCode === 'NONE') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const codeConfig = VAT_CODE_CONFIG[vatCode];
|
const codeConfig = VAT_CODE_CONFIG[vatCode];
|
||||||
|
|
||||||
// Calculate net amount (amount without VAT)
|
// Calculate net amount (amount without VAT)
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ import dayjs from 'dayjs';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useCompany } from '@/hooks/useCompany';
|
import { useCompany } from '@/hooks/useCompany';
|
||||||
import { useReconciliationStore } from '@/stores/reconciliationStore';
|
import { useReconciliationStore } from '@/stores/reconciliationStore';
|
||||||
import { useCompanyStore } from '@/stores/companyStore';
|
|
||||||
import { useActiveBankConnections } from '@/api/queries/bankConnectionQueries';
|
import { useActiveBankConnections } from '@/api/queries/bankConnectionQueries';
|
||||||
import { usePendingBankTransactions } from '@/api/queries/bankTransactionQueries';
|
import { usePendingBankTransactions } from '@/api/queries/bankTransactionQueries';
|
||||||
import { useActiveAccounts } from '@/api/queries/accountQueries';
|
import { useActiveAccounts } from '@/api/queries/accountQueries';
|
||||||
|
|
@ -69,12 +68,11 @@ interface MatchSuggestion {
|
||||||
export default function Bankafstemning() {
|
export default function Bankafstemning() {
|
||||||
const { company } = useCompany();
|
const { company } = useCompany();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { activeCompany } = useCompanyStore();
|
|
||||||
|
|
||||||
// Fetch data from API
|
// Fetch data from API
|
||||||
const { data: bankConnections = [], isLoading: connectionsLoading } = useActiveBankConnections(activeCompany?.id);
|
const { data: bankConnections = [], isLoading: connectionsLoading } = useActiveBankConnections(company?.id);
|
||||||
const { data: pendingTransactions = [], isLoading: transactionsLoading } = usePendingBankTransactions(activeCompany?.id);
|
const { data: pendingTransactions = [], isLoading: transactionsLoading } = usePendingBankTransactions(company?.id);
|
||||||
const { data: activeAccounts = [] } = useActiveAccounts(activeCompany?.id);
|
const { data: activeAccounts = [] } = useActiveAccounts(company?.id);
|
||||||
|
|
||||||
const isLoading = connectionsLoading || transactionsLoading;
|
const isLoading = connectionsLoading || transactionsLoading;
|
||||||
|
|
||||||
|
|
@ -82,7 +80,7 @@ export default function Bankafstemning() {
|
||||||
const bankAccounts = bankConnections.flatMap(conn =>
|
const bankAccounts = bankConnections.flatMap(conn =>
|
||||||
(conn.accounts || []).map(acc => ({
|
(conn.accounts || []).map(acc => ({
|
||||||
id: acc.accountId,
|
id: acc.accountId,
|
||||||
companyId: activeCompany?.id || '',
|
companyId: company?.id || '',
|
||||||
name: acc.name || acc.iban,
|
name: acc.name || acc.iban,
|
||||||
bankName: conn.aspspName,
|
bankName: conn.aspspName,
|
||||||
accountNumber: acc.iban,
|
accountNumber: acc.iban,
|
||||||
|
|
@ -697,9 +695,9 @@ export default function Bankafstemning() {
|
||||||
placeholder="Vælg momskode"
|
placeholder="Vælg momskode"
|
||||||
allowClear
|
allowClear
|
||||||
options={[
|
options={[
|
||||||
{ value: 'K25', label: 'K25 - Indgående moms 25%' },
|
{ value: 'I25', label: 'I25 - Indgående moms 25%' },
|
||||||
{ value: 'S25', label: 'S25 - Udgående moms 25%' },
|
{ value: 'U25', label: 'U25 - Udgående moms 25%' },
|
||||||
{ value: 'NONE', label: 'Ingen moms' },
|
{ value: 'INGEN', label: 'Ingen moms' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import { Line, Pie, Column } from '@ant-design/charts';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useCompany } from '@/hooks/useCompany';
|
import { useCompany } from '@/hooks/useCompany';
|
||||||
import { useCompanyStore } from '@/stores/companyStore';
|
|
||||||
import { usePeriodStore } from '@/stores/periodStore';
|
import { usePeriodStore } from '@/stores/periodStore';
|
||||||
import { useAccountBalances } from '@/api/queries/accountQueries';
|
import { useAccountBalances } from '@/api/queries/accountQueries';
|
||||||
import { useInvoices } from '@/api/queries/invoiceQueries';
|
import { useInvoices } from '@/api/queries/invoiceQueries';
|
||||||
|
|
@ -45,7 +44,6 @@ interface RecentTransaction {
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { company } = useCompany();
|
const { company } = useCompany();
|
||||||
const { activeCompany } = useCompanyStore();
|
|
||||||
const { currentFiscalYear } = usePeriodStore();
|
const { currentFiscalYear } = usePeriodStore();
|
||||||
|
|
||||||
// Define date interval - always format as YYYY-MM-DD for GraphQL DateOnly type
|
// Define date interval - always format as YYYY-MM-DD for GraphQL DateOnly type
|
||||||
|
|
@ -57,16 +55,16 @@ export default function Dashboard() {
|
||||||
: dayjs().endOf('year').format('YYYY-MM-DD');
|
: dayjs().endOf('year').format('YYYY-MM-DD');
|
||||||
|
|
||||||
const { data: balances = [], isLoading: balancesLoading } = useAccountBalances(
|
const { data: balances = [], isLoading: balancesLoading } = useAccountBalances(
|
||||||
activeCompany?.id,
|
company?.id,
|
||||||
currentFiscalYear ? {
|
currentFiscalYear ? {
|
||||||
startDate: dayjs(currentFiscalYear.startDate),
|
startDate: dayjs(currentFiscalYear.startDate),
|
||||||
endDate: dayjs(currentFiscalYear.endDate),
|
endDate: dayjs(currentFiscalYear.endDate),
|
||||||
} : undefined
|
} : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: invoices = [], isLoading: invoicesLoading } = useInvoices(activeCompany?.id);
|
const { data: invoices = [], isLoading: invoicesLoading } = useInvoices(company?.id);
|
||||||
const { data: vatReport, isLoading: vatLoading } = useVatReport(
|
const { data: vatReport, isLoading: vatLoading } = useVatReport(
|
||||||
activeCompany?.id,
|
company?.id,
|
||||||
periodStart,
|
periodStart,
|
||||||
periodEnd
|
periodEnd
|
||||||
);
|
);
|
||||||
|
|
@ -252,7 +250,6 @@ export default function Dashboard() {
|
||||||
value={metrics.cashPosition}
|
value={metrics.cashPosition}
|
||||||
precision={2}
|
precision={2}
|
||||||
prefix={<BankOutlined />}
|
prefix={<BankOutlined />}
|
||||||
suffix="kr."
|
|
||||||
formatter={(value) => formatCurrency(value as number)}
|
formatter={(value) => formatCurrency(value as number)}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
|
|
@ -270,7 +267,6 @@ export default function Dashboard() {
|
||||||
title="Tilgodehavender"
|
title="Tilgodehavender"
|
||||||
value={metrics.accountsReceivable}
|
value={metrics.accountsReceivable}
|
||||||
precision={2}
|
precision={2}
|
||||||
suffix="kr."
|
|
||||||
valueStyle={{ color: accountingColors.credit }}
|
valueStyle={{ color: accountingColors.credit }}
|
||||||
formatter={(value) => formatCurrency(value as number)}
|
formatter={(value) => formatCurrency(value as number)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -294,7 +290,6 @@ export default function Dashboard() {
|
||||||
title="Kreditorer"
|
title="Kreditorer"
|
||||||
value={metrics.accountsPayable}
|
value={metrics.accountsPayable}
|
||||||
precision={2}
|
precision={2}
|
||||||
suffix="kr."
|
|
||||||
valueStyle={{ color: accountingColors.debit }}
|
valueStyle={{ color: accountingColors.debit }}
|
||||||
formatter={(value) => formatCurrency(value as number)}
|
formatter={(value) => formatCurrency(value as number)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -313,7 +308,6 @@ export default function Dashboard() {
|
||||||
title="Moms til betaling"
|
title="Moms til betaling"
|
||||||
value={metrics.vatLiability}
|
value={metrics.vatLiability}
|
||||||
precision={2}
|
precision={2}
|
||||||
suffix="kr."
|
|
||||||
formatter={(value) => formatCurrency(value as number)}
|
formatter={(value) => formatCurrency(value as number)}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export default function Eksport() {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('Der opstod en fejl under eksport');
|
showError('Der opstod en fejl under eksport');
|
||||||
console.error('SAF-T export error:', error);
|
if (import.meta.env.DEV) console.error('SAF-T export error:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,7 @@ export default function Fakturaer() {
|
||||||
const handleAddLine = () => {
|
const handleAddLine = () => {
|
||||||
setEditingLine(null);
|
setEditingLine(null);
|
||||||
lineForm.resetFields();
|
lineForm.resetFields();
|
||||||
lineForm.setFieldsValue({ quantity: 1, vatCode: 'S25' });
|
lineForm.setFieldsValue({ quantity: 1, vatCode: 'U25' });
|
||||||
setIsLineModalOpen(true);
|
setIsLineModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -862,7 +862,7 @@ export default function Fakturaer() {
|
||||||
lineForm.setFieldsValue({
|
lineForm.setFieldsValue({
|
||||||
description: product.description || product.name,
|
description: product.description || product.name,
|
||||||
unitPrice: product.unitPrice,
|
unitPrice: product.unitPrice,
|
||||||
vatCode: product.vatCode === 'U25' ? 'S25' : product.vatCode,
|
vatCode: product.vatCode,
|
||||||
unit: product.unit || undefined,
|
unit: product.unit || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -930,7 +930,7 @@ export default function Fakturaer() {
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
options={[
|
options={[
|
||||||
{ value: 'S25', label: 'S25 - 25% moms' },
|
{ value: 'U25', label: 'U25 - 25% moms' },
|
||||||
{ value: 'U0', label: 'U0 - Momsfrit' },
|
{ value: 'U0', label: 'U0 - Momsfrit' },
|
||||||
{ value: 'UEU', label: 'UEU - EU-salg' },
|
{ value: 'UEU', label: 'UEU - EU-salg' },
|
||||||
{ value: 'UEXP', label: 'UEXP - Eksport' },
|
{ value: 'UEXP', label: 'UEXP - Eksport' },
|
||||||
|
|
|
||||||
|
|
@ -424,8 +424,8 @@ export default function Kontooversigt() {
|
||||||
allowClear
|
allowClear
|
||||||
placeholder="Vælg..."
|
placeholder="Vælg..."
|
||||||
options={[
|
options={[
|
||||||
{ value: 'S25', label: 'S25 - Udgående (Salg)' },
|
{ value: 'U25', label: 'U25 - Udgående (Salg)' },
|
||||||
{ value: 'K25', label: 'K25 - Indgående (Køb)' },
|
{ value: 'I25', label: 'I25 - Indgående (Køb)' },
|
||||||
{ value: 'E0', label: 'E0 - EU-salg' },
|
{ value: 'E0', label: 'E0 - EU-salg' },
|
||||||
{ value: 'U0', label: 'U0 - Eksport' },
|
{ value: 'U0', label: 'U0 - Eksport' },
|
||||||
]}
|
]}
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,7 @@ export default function Kreditnotaer() {
|
||||||
const handleAddLine = () => {
|
const handleAddLine = () => {
|
||||||
setEditingLine(null);
|
setEditingLine(null);
|
||||||
lineForm.resetFields();
|
lineForm.resetFields();
|
||||||
lineForm.setFieldsValue({ quantity: 1, vatCode: 'S25' });
|
lineForm.setFieldsValue({ quantity: 1, vatCode: 'U25' });
|
||||||
setIsLineModalOpen(true);
|
setIsLineModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -854,7 +854,7 @@ export default function Kreditnotaer() {
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
options={[
|
options={[
|
||||||
{ value: 'S25', label: 'S25 - 25% moms' },
|
{ value: 'U25', label: 'U25 - 25% moms' },
|
||||||
{ value: 'U0', label: 'U0 - Momsfrit' },
|
{ value: 'U0', label: 'U0 - Momsfrit' },
|
||||||
{ value: 'UEU', label: 'UEU - EU-salg' },
|
{ value: 'UEU', label: 'UEU - EU-salg' },
|
||||||
{ value: 'UEXP', label: 'UEXP - Eksport' },
|
{ value: 'UEXP', label: 'UEXP - Eksport' },
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ import {
|
||||||
import { Pie } from '@ant-design/charts';
|
import { Pie } from '@ant-design/charts';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useCompany } from '@/hooks/useCompany';
|
import { useCompany } from '@/hooks/useCompany';
|
||||||
import { useCompanyStore } from '@/stores/companyStore';
|
|
||||||
import { useVatReport } from '@/api/queries/vatQueries';
|
import { useVatReport } from '@/api/queries/vatQueries';
|
||||||
import { formatCurrency, formatPeriod } from '@/lib/formatters';
|
import { formatCurrency, formatPeriod } from '@/lib/formatters';
|
||||||
import { accountingColors } from '@/styles/theme';
|
import { accountingColors } from '@/styles/theme';
|
||||||
|
|
@ -47,18 +46,25 @@ interface VATBox {
|
||||||
|
|
||||||
export default function Momsindberetning() {
|
export default function Momsindberetning() {
|
||||||
const { company } = useCompany();
|
const { company } = useCompany();
|
||||||
const { activeCompany } = useCompanyStore();
|
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState<dayjs.Dayjs>(
|
const [selectedPeriod, setSelectedPeriod] = useState<dayjs.Dayjs>(
|
||||||
dayjs().subtract(1, 'month').startOf('month')
|
dayjs().subtract(1, 'month').startOf('month')
|
||||||
);
|
);
|
||||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||||
const [periodType, setPeriodType] = useState<'monthly' | 'quarterly'>('quarterly');
|
const [periodType, setPeriodType] = useState<'monthly' | 'quarterly' | 'half-yearly' | 'yearly'>('quarterly');
|
||||||
|
|
||||||
// Calculate period dates based on selection
|
// Calculate period dates based on selection
|
||||||
const periodStart = useMemo(() => {
|
const periodStart = useMemo(() => {
|
||||||
if (periodType === 'quarterly') {
|
if (periodType === 'quarterly') {
|
||||||
return selectedPeriod.startOf('quarter').format('YYYY-MM-DD');
|
return selectedPeriod.startOf('quarter').format('YYYY-MM-DD');
|
||||||
}
|
}
|
||||||
|
if (periodType === 'half-yearly') {
|
||||||
|
const month = selectedPeriod.month(); // 0-indexed
|
||||||
|
const halfStart = month < 6 ? 0 : 6;
|
||||||
|
return selectedPeriod.month(halfStart).startOf('month').format('YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
if (periodType === 'yearly') {
|
||||||
|
return selectedPeriod.startOf('year').format('YYYY-MM-DD');
|
||||||
|
}
|
||||||
return selectedPeriod.startOf('month').format('YYYY-MM-DD');
|
return selectedPeriod.startOf('month').format('YYYY-MM-DD');
|
||||||
}, [selectedPeriod, periodType]);
|
}, [selectedPeriod, periodType]);
|
||||||
|
|
||||||
|
|
@ -66,12 +72,20 @@ export default function Momsindberetning() {
|
||||||
if (periodType === 'quarterly') {
|
if (periodType === 'quarterly') {
|
||||||
return selectedPeriod.endOf('quarter').format('YYYY-MM-DD');
|
return selectedPeriod.endOf('quarter').format('YYYY-MM-DD');
|
||||||
}
|
}
|
||||||
|
if (periodType === 'half-yearly') {
|
||||||
|
const month = selectedPeriod.month(); // 0-indexed
|
||||||
|
const halfEnd = month < 6 ? 5 : 11;
|
||||||
|
return selectedPeriod.month(halfEnd).endOf('month').format('YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
if (periodType === 'yearly') {
|
||||||
|
return selectedPeriod.endOf('year').format('YYYY-MM-DD');
|
||||||
|
}
|
||||||
return selectedPeriod.endOf('month').format('YYYY-MM-DD');
|
return selectedPeriod.endOf('month').format('YYYY-MM-DD');
|
||||||
}, [selectedPeriod, periodType]);
|
}, [selectedPeriod, periodType]);
|
||||||
|
|
||||||
// Fetch VAT report from backend
|
// Fetch VAT report from backend
|
||||||
const { data: vatReport, isLoading, error } = useVatReport(
|
const { data: vatReport, isLoading, error } = useVatReport(
|
||||||
activeCompany?.id,
|
company?.id,
|
||||||
periodStart,
|
periodStart,
|
||||||
periodEnd
|
periodEnd
|
||||||
);
|
);
|
||||||
|
|
@ -255,20 +269,44 @@ export default function Momsindberetning() {
|
||||||
<Select
|
<Select
|
||||||
value={periodType}
|
value={periodType}
|
||||||
onChange={setPeriodType}
|
onChange={setPeriodType}
|
||||||
style={{ width: 120 }}
|
style={{ width: 140 }}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'monthly', label: 'Månedlig' },
|
{ value: 'monthly', label: 'Månedlig' },
|
||||||
{ value: 'quarterly', label: 'Kvartalsvis' },
|
{ value: 'quarterly', label: 'Kvartalsvis' },
|
||||||
|
{ value: 'half-yearly', label: 'Halvårlig' },
|
||||||
|
{ value: 'yearly', label: 'Årlig' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
picker={periodType === 'quarterly' ? 'quarter' : 'month'}
|
picker={periodType === 'quarterly' ? 'quarter' : periodType === 'yearly' ? 'year' : 'month'}
|
||||||
value={selectedPeriod}
|
value={selectedPeriod}
|
||||||
onChange={(date) => date && setSelectedPeriod(date)}
|
onChange={(date) => date && setSelectedPeriod(date)}
|
||||||
format={periodType === 'quarterly' ? '[Q]Q YYYY' : 'MMMM YYYY'}
|
format={
|
||||||
|
periodType === 'quarterly' ? '[Q]Q YYYY'
|
||||||
|
: periodType === 'yearly' ? 'YYYY'
|
||||||
|
: 'MMMM YYYY'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Tag color="blue">
|
<Tag color="blue">
|
||||||
Frist: {dayjs(selectedPeriod).add(1, 'month').endOf('month').format('D. MMMM YYYY')}
|
Frist: {(() => {
|
||||||
|
const pEnd = dayjs(periodEnd);
|
||||||
|
switch (periodType) {
|
||||||
|
case 'monthly':
|
||||||
|
// 25th of next month
|
||||||
|
return pEnd.add(1, 'month').date(25).format('D. MMMM YYYY');
|
||||||
|
case 'quarterly':
|
||||||
|
// 1st day of 2nd month after quarter end
|
||||||
|
return pEnd.add(2, 'month').startOf('month').format('D. MMMM YYYY');
|
||||||
|
case 'half-yearly':
|
||||||
|
// 1st day of 3rd month after half-year end
|
||||||
|
return pEnd.add(3, 'month').startOf('month').format('D. MMMM YYYY');
|
||||||
|
case 'yearly':
|
||||||
|
// 6 months after fiscal year end
|
||||||
|
return pEnd.add(6, 'month').format('D. MMMM YYYY');
|
||||||
|
default:
|
||||||
|
return pEnd.add(1, 'month').endOf('month').format('D. MMMM YYYY');
|
||||||
|
}
|
||||||
|
})()}
|
||||||
</Tag>
|
</Tag>
|
||||||
{vatReport && (
|
{vatReport && (
|
||||||
<Tag color="green">{vatReport.transactionCount} transaktioner</Tag>
|
<Tag color="green">{vatReport.transactionCount} transaktioner</Tag>
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ export default function Ordrer() {
|
||||||
addLineForm.resetFields();
|
addLineForm.resetFields();
|
||||||
addLineForm.setFieldsValue({
|
addLineForm.setFieldsValue({
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
vatCode: 'S25',
|
vatCode: 'U25',
|
||||||
});
|
});
|
||||||
setAddLineMode('product');
|
setAddLineMode('product');
|
||||||
setSelectedProductId(null);
|
setSelectedProductId(null);
|
||||||
|
|
@ -193,7 +193,7 @@ export default function Ordrer() {
|
||||||
description: product.name,
|
description: product.name,
|
||||||
unitPrice: product.unitPrice,
|
unitPrice: product.unitPrice,
|
||||||
unit: product.unit || 'stk',
|
unit: product.unit || 'stk',
|
||||||
vatCode: product.vatCode || 'S25',
|
vatCode: product.vatCode || 'U25',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -825,7 +825,7 @@ export default function Ordrer() {
|
||||||
addLineForm.resetFields();
|
addLineForm.resetFields();
|
||||||
addLineForm.setFieldsValue({
|
addLineForm.setFieldsValue({
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
vatCode: 'S25',
|
vatCode: 'U25',
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
optionType="button"
|
optionType="button"
|
||||||
|
|
@ -918,7 +918,7 @@ export default function Ordrer() {
|
||||||
<Select
|
<Select
|
||||||
disabled={addLineMode === 'product' && !!selectedProductId}
|
disabled={addLineMode === 'product' && !!selectedProductId}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'S25', label: 'S25 - Salgsmoms 25%' },
|
{ value: 'U25', label: 'U25 - Salgsmoms 25%' },
|
||||||
{ value: 'S0', label: 'S0 - Momsfrit salg' },
|
{ value: 'S0', label: 'S0 - Momsfrit salg' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -308,7 +308,7 @@ export default function UserSettings() {
|
||||||
Modtag vigtige opdateringer om din konto
|
Modtag vigtige opdateringer om din konto
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<Switch defaultChecked={mockUser.notifications.email} />
|
<Switch />
|
||||||
</div>
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
|
@ -324,7 +324,7 @@ export default function UserSettings() {
|
||||||
Få en opsummering af ugens bogføring hver mandag
|
Få en opsummering af ugens bogføring hver mandag
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<Switch defaultChecked={mockUser.notifications.weeklyReport} />
|
<Switch />
|
||||||
</div>
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
|
@ -340,7 +340,7 @@ export default function UserSettings() {
|
||||||
Få besked når momsfrister eller andre deadlines nærmer sig
|
Få besked når momsfrister eller andre deadlines nærmer sig
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<Switch defaultChecked={mockUser.notifications.deadlineReminders} />
|
<Switch />
|
||||||
</div>
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
|
@ -360,7 +360,7 @@ export default function UserSettings() {
|
||||||
Modtag notifikationer direkte i browseren
|
Modtag notifikationer direkte i browseren
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<Switch defaultChecked={mockUser.notifications.browser} />
|
<Switch />
|
||||||
</div>
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,15 +19,7 @@ export type VATCode =
|
||||||
| 'IVV' // Import varer verden (0%)
|
| 'IVV' // Import varer verden (0%)
|
||||||
| 'IVY' // Import ydelser verden (0%)
|
| 'IVY' // Import ydelser verden (0%)
|
||||||
| 'REP' // Repræsentation (25%, 25% fradrag)
|
| 'REP' // Repræsentation (25%, 25% fradrag)
|
||||||
| 'INGEN' // Ingen moms
|
| 'INGEN'; // Ingen moms
|
||||||
// Legacy codes kept for backwards compatibility with other modules
|
|
||||||
| 'S25' // @deprecated Use U25
|
|
||||||
| 'K25' // @deprecated Use I25
|
|
||||||
| 'EU_VARE' // @deprecated Use IEUV
|
|
||||||
| 'EU_YDELSE' // @deprecated Use IEUY
|
|
||||||
| 'MOMSFRI' // @deprecated Use INGEN
|
|
||||||
| 'EKSPORT' // @deprecated Use UEXP
|
|
||||||
| 'NONE'; // @deprecated Use INGEN
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VAT code type classification
|
* VAT code type classification
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue