Audit v2: fix security, data integrity, compliance, bugs, encoding, UX
Backend Security & Data Integrity: - Block negative debit/credit amounts that bypass balance validation - Require document date at posting (was optional, bypassing fiscal year checks) - Fix event sourcing anti-pattern: timestamps now stored in events, not UtcNow in Apply - Add [Authorize] to BankingController OAuth callback - Add company access check on attachment downloads - Validate CVR in CompanyAggregate.Create and CompanyAggregate.Update - Require company CVR for invoice creation (Momsloven §52) - Delete leftover WeatherForecastController - Fix duplicate migration number 007 (renamed to 007b) - Remove dead code in VatCalculationService (identical if/else branches) Accounting Compliance: - Add missing VAT accounts to StandardDanishAccounts (5610, 5611, 5620) - Populate SAF-T TaxInformation on transaction lines (was always null) - Add AuditFileCountry and TaxRegistrationNumber to SAF-T header Critical Frontend Bugs: - Fix Dashboard <a href> causing full page reloads (now uses React Router Link) - Wire Kassekladde filters to actual data (account, status, date range) - Pre-populate form when editing existing Kassekladde drafts - Add detail drawer for "Vis detaljer" action (was just a toast) - Toggle advanced filters with "Flere filtre" button - CloseFiscalYearWizard now actually posts closing entries via mutations - "Create next year" checkbox now creates the next fiscal year Danish Character Encoding (~50 fixes): - Fix ø/æ/å across Momsindberetning, DocumentUploadModal, Bankafstemning, Kontooversigt, CloseFiscalYearWizard, vatCodes, periodStore, periods, accounting, types/periods Dead Buttons & UX: - Disable Momsindberetning PDF/Export buttons with tooltips - FiscalYearSelector "Administrer" now navigates to Settings - Settings bank tab now uses real BankConnectionsTab component - Bankafstemning save button disabled with development tooltip - Replace hardcoded account options with real API data (Bankafstemning, Fakturaer) - Header help button shows info message, notification bell shows popover Consistency & Quality: - Remove 7 console.log statements from production code - Adopt PageHeader on 6 remaining pages (Kreditnotaer, Settings, Admin, etc.) - Standardize loading states to Skeleton pattern (5 pages) - Replace deprecated bodyStyle prop on Ant Design Cards - Standardize date format to DD-MM-YYYY - Fix sidebar width mismatch in designTokens - Fix Kontooversigt breadcrumb pointing to non-existent route Accessibility: - Add aria-label to sidebar navigation - Add +/- prefix to AmountText for color-blind users - Fix CompanySwitcher permanent skeleton when no companies Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a1c2af6027
commit
709d0a4739
50 changed files with 676 additions and 372 deletions
|
|
@ -1,4 +1,6 @@
|
|||
using Books.Api.Domain;
|
||||
using Books.Api.Domain.Invoices;
|
||||
using Books.Api.EventFlow.Repositories;
|
||||
using Books.Api.Invoicing.Services;
|
||||
using EventFlow.Commands;
|
||||
|
||||
|
|
@ -7,9 +9,11 @@ namespace Books.Api.Commands.Invoices;
|
|||
/// <summary>
|
||||
/// Command handler for creating invoices.
|
||||
/// Auto-assigns a sequential invoice number if one is not provided.
|
||||
/// Validates the company has a CVR number (required for invoicing).
|
||||
/// </summary>
|
||||
public class CreateInvoiceCommandHandler(
|
||||
IInvoiceNumberService invoiceNumberService)
|
||||
IInvoiceNumberService invoiceNumberService,
|
||||
ICompanyRepository companyRepository)
|
||||
: CommandHandler<InvoiceAggregate, InvoiceId, CreateInvoiceCommand>
|
||||
{
|
||||
public override async Task ExecuteAsync(
|
||||
|
|
@ -17,6 +21,24 @@ public class CreateInvoiceCommandHandler(
|
|||
CreateInvoiceCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Validate company has a CVR number (required for invoicing per Danish law)
|
||||
var company = await companyRepository.GetByIdAsync(command.CompanyId, cancellationToken);
|
||||
if (company == null)
|
||||
{
|
||||
throw new DomainException(
|
||||
"COMPANY_NOT_FOUND",
|
||||
$"Company '{command.CompanyId}' not found",
|
||||
$"Virksomheden '{command.CompanyId}' blev ikke fundet");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(company.Cvr))
|
||||
{
|
||||
throw new DomainException(
|
||||
"CVR_REQUIRED_FOR_INVOICE",
|
||||
"Company must have a CVR number to create invoices. Please update company settings.",
|
||||
"Virksomheden skal have et CVR-nummer for at oprette fakturaer. Opdater venligst virksomhedsindstillinger.");
|
||||
}
|
||||
|
||||
// Auto-assign invoice number if not provided
|
||||
var invoiceNumber = command.InvoiceNumber;
|
||||
if (string.IsNullOrWhiteSpace(invoiceNumber))
|
||||
|
|
|
|||
|
|
@ -94,8 +94,16 @@ public class MarkJournalEntryDraftPostedCommandHandler(
|
|||
$"Regnskabsåret er {fiscalYear.Status}. Kun åbne regnskabsår tillader bogføring.");
|
||||
}
|
||||
|
||||
// Validate document date falls within fiscal year range (if document date is set)
|
||||
if (draft?.DocumentDate != null)
|
||||
// Validate document date is set (required for posting per Bogføringsloven)
|
||||
if (draft?.DocumentDate == null)
|
||||
{
|
||||
throw new DomainException(
|
||||
"DOCUMENT_DATE_REQUIRED",
|
||||
"Document date (bilagsdato) is required for posting a journal entry",
|
||||
"Bilagsdato er påkrævet for bogføring af en postering");
|
||||
}
|
||||
|
||||
// Validate document date falls within fiscal year range
|
||||
{
|
||||
var documentDate = DateOnly.FromDateTime(draft.DocumentDate.Value);
|
||||
var fyStart = DateOnly.FromDateTime(fiscalYear.StartDate);
|
||||
|
|
|
|||
|
|
@ -197,6 +197,20 @@ public class AttachmentController(
|
|||
return BadRequest(new { error = "INVALID_PATH", message = "Invalid storage path" });
|
||||
}
|
||||
|
||||
// Look up the attachment to verify company access
|
||||
var attachment = await attachmentRepository.GetByStoragePathAsync(storagePath, cancellationToken);
|
||||
if (attachment == null)
|
||||
{
|
||||
return NotFound(new { error = "FILE_NOT_FOUND", message = "Attachment not found" });
|
||||
}
|
||||
|
||||
// Verify the user has access to the company that owns this attachment
|
||||
var access = await companyAccess.GetAccessAsync(attachment.CompanyId, cancellationToken);
|
||||
if (access == null)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var file = await fileStorage.GetAsync(storagePath, cancellationToken);
|
||||
|
||||
if (file == null)
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@ using Books.Api.Commands.BankConnections;
|
|||
using Books.Api.Domain.BankConnections;
|
||||
using EventFlow;
|
||||
using EventFlow.Aggregates.ExecutionResults;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Books.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/banking")]
|
||||
[Authorize]
|
||||
public class BankingController : ControllerBase
|
||||
{
|
||||
private readonly ICommandBus _commandBus;
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Books.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("[controller]")]
|
||||
public class WeatherForecastController : ControllerBase
|
||||
{
|
||||
private static readonly string[] Summaries =
|
||||
[
|
||||
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
|
||||
];
|
||||
|
||||
[HttpGet(Name = "GetWeatherForecast")]
|
||||
public IEnumerable<WeatherForecast> Get()
|
||||
{
|
||||
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
|
||||
{
|
||||
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
|
||||
TemperatureC = Random.Shared.Next(-20, 55),
|
||||
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ public class AttachmentAggregate(AttachmentId id)
|
|||
_isCreated = true;
|
||||
_companyId = e.CompanyId;
|
||||
_transactionId = e.TransactionId;
|
||||
_uploadedAt = DateTimeOffset.UtcNow;
|
||||
_uploadedAt = e.UploadedAt;
|
||||
}
|
||||
|
||||
public void Apply(AttachmentLinkedToTransactionEvent e)
|
||||
|
|
@ -127,6 +127,7 @@ public class AttachmentAggregate(AttachmentId id)
|
|||
fileSize,
|
||||
storagePath.Trim(),
|
||||
uploadedBy,
|
||||
DateTimeOffset.UtcNow,
|
||||
draftId?.Trim(),
|
||||
transactionId?.Trim()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ public class AttachmentUploadedEvent(
|
|||
long fileSize,
|
||||
string storagePath,
|
||||
string uploadedBy,
|
||||
DateTimeOffset uploadedAt,
|
||||
string? draftId = null,
|
||||
string? transactionId = null) : AggregateEvent<AttachmentAggregate, AttachmentId>
|
||||
{
|
||||
|
|
@ -46,6 +47,11 @@ public class AttachmentUploadedEvent(
|
|||
|
||||
public string UploadedBy { get; } = uploadedBy;
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the attachment was uploaded.
|
||||
/// </summary>
|
||||
public DateTimeOffset UploadedAt { get; } = uploadedAt;
|
||||
|
||||
/// <summary>
|
||||
/// Optional reference to journal entry draft.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@ public class CompanyAggregate(CompanyId id) : AggregateRoot<CompanyAggregate, Co
|
|||
if (fiscalYearStartMonth < 1 || fiscalYearStartMonth > 12)
|
||||
throw new DomainException("Fiscal year start month must be between 1 and 12");
|
||||
|
||||
// Validate CVR number if provided
|
||||
if (!string.IsNullOrWhiteSpace(cvr) && !CvrValidator.IsValid(cvr.Trim()))
|
||||
throw new DomainException(
|
||||
"INVALID_CVR",
|
||||
$"CVR number '{cvr}' is not valid. Must be 8 digits with valid checksum.",
|
||||
$"CVR-nummer '{cvr}' er ugyldigt. Skal være 8 cifre med gyldig kontrolsum.");
|
||||
|
||||
Emit(new CompanyCreatedEvent(
|
||||
name.Trim(),
|
||||
cvr?.Trim(),
|
||||
|
|
@ -66,6 +73,13 @@ public class CompanyAggregate(CompanyId id) : AggregateRoot<CompanyAggregate, Co
|
|||
if (fiscalYearStartMonth < 1 || fiscalYearStartMonth > 12)
|
||||
throw new DomainException("Fiscal year start month must be between 1 and 12");
|
||||
|
||||
// Validate CVR number if provided
|
||||
if (!string.IsNullOrWhiteSpace(cvr) && !CvrValidator.IsValid(cvr.Trim()))
|
||||
throw new DomainException(
|
||||
"INVALID_CVR",
|
||||
$"CVR number '{cvr}' is not valid. Must be 8 digits with valid checksum.",
|
||||
$"CVR-nummer '{cvr}' er ugyldigt. Skal være 8 cifre med gyldig kontrolsum.");
|
||||
|
||||
Emit(new CompanyUpdatedEvent(
|
||||
name.Trim(),
|
||||
cvr?.Trim(),
|
||||
|
|
|
|||
|
|
@ -255,11 +255,28 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
|||
|
||||
/// <summary>
|
||||
/// Validates a single draft line.
|
||||
/// Amounts must be non-negative.
|
||||
/// A line cannot have both DebitAmount > 0 AND CreditAmount > 0.
|
||||
/// At least one of DebitAmount or CreditAmount must be > 0.
|
||||
/// </summary>
|
||||
private static void ValidateDraftLine(DraftLine line)
|
||||
{
|
||||
if (line.DebitAmount < 0)
|
||||
{
|
||||
throw new DomainException(
|
||||
"NEGATIVE_DEBIT_AMOUNT",
|
||||
$"Line {line.LineNumber} has a negative debit amount. Amounts must be non-negative.",
|
||||
$"Linje {line.LineNumber} har et negativt debetbeløb. Beløb skal være ikke-negative.");
|
||||
}
|
||||
|
||||
if (line.CreditAmount < 0)
|
||||
{
|
||||
throw new DomainException(
|
||||
"NEGATIVE_CREDIT_AMOUNT",
|
||||
$"Line {line.LineNumber} has a negative credit amount. Amounts must be non-negative.",
|
||||
$"Linje {line.LineNumber} har et negativt kreditbeløb. Beløb skal være ikke-negative.");
|
||||
}
|
||||
|
||||
if (line.DebitAmount > 0 && line.CreditAmount > 0)
|
||||
{
|
||||
throw new DomainException(
|
||||
|
|
|
|||
|
|
@ -168,24 +168,11 @@ public class VatCalculationService : IVatCalculationService
|
|||
// - 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
|
||||
if (isInputVat)
|
||||
vatLine = vatLine with
|
||||
{
|
||||
// Purchases: VAT follows the expense direction (typically debit)
|
||||
vatLine = vatLine with
|
||||
{
|
||||
DebitAmount = isDebit ? vatAmount : 0,
|
||||
CreditAmount = !isDebit ? vatAmount : 0
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// Sales: VAT follows the revenue direction (typically credit)
|
||||
vatLine = vatLine with
|
||||
{
|
||||
DebitAmount = isDebit ? vatAmount : 0,
|
||||
CreditAmount = !isDebit ? vatAmount : 0
|
||||
};
|
||||
}
|
||||
DebitAmount = isDebit ? vatAmount : 0,
|
||||
CreditAmount = !isDebit ? vatAmount : 0
|
||||
};
|
||||
|
||||
vatLines.Add(vatLine);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,12 +9,14 @@ public class UserCompanyAccessGrantedEvent(
|
|||
string userId,
|
||||
string companyId,
|
||||
CompanyRole role,
|
||||
string grantedBy) : AggregateEvent<UserCompanyAccessAggregate, UserCompanyAccessId>
|
||||
string grantedBy,
|
||||
DateTimeOffset grantedAt) : AggregateEvent<UserCompanyAccessAggregate, UserCompanyAccessId>
|
||||
{
|
||||
public string UserId { get; } = userId;
|
||||
public string CompanyId { get; } = companyId;
|
||||
public CompanyRole Role { get; } = role;
|
||||
public string GrantedBy { get; } = grantedBy;
|
||||
public DateTimeOffset GrantedAt { get; } = grantedAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -34,7 +36,9 @@ public class UserCompanyAccessRoleChangedEvent(
|
|||
/// Emitted when a user's access to a company is revoked.
|
||||
/// </summary>
|
||||
public class UserCompanyAccessRevokedEvent(
|
||||
string revokedBy) : AggregateEvent<UserCompanyAccessAggregate, UserCompanyAccessId>
|
||||
string revokedBy,
|
||||
DateTimeOffset revokedAt) : AggregateEvent<UserCompanyAccessAggregate, UserCompanyAccessId>
|
||||
{
|
||||
public string RevokedBy { get; } = revokedBy;
|
||||
public DateTimeOffset RevokedAt { get; } = revokedAt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ public class UserCompanyAccessAggregate : AggregateRoot<UserCompanyAccessAggrega
|
|||
}
|
||||
|
||||
// If previously revoked, we're re-granting
|
||||
Emit(new UserCompanyAccessGrantedEvent(userId, companyId, role, grantedBy));
|
||||
Emit(new UserCompanyAccessGrantedEvent(userId, companyId, role, grantedBy, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -73,7 +73,7 @@ public class UserCompanyAccessAggregate : AggregateRoot<UserCompanyAccessAggrega
|
|||
"Adgang er allerede tilbagekaldt");
|
||||
}
|
||||
|
||||
Emit(new UserCompanyAccessRevokedEvent(revokedBy));
|
||||
Emit(new UserCompanyAccessRevokedEvent(revokedBy, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
public void Apply(UserCompanyAccessGrantedEvent e)
|
||||
|
|
@ -82,7 +82,7 @@ public class UserCompanyAccessAggregate : AggregateRoot<UserCompanyAccessAggrega
|
|||
CompanyId = e.CompanyId;
|
||||
Role = e.Role;
|
||||
GrantedBy = e.GrantedBy;
|
||||
GrantedAt = DateTimeOffset.UtcNow;
|
||||
GrantedAt = e.GrantedAt;
|
||||
IsActive = true;
|
||||
RevokedAt = null;
|
||||
RevokedBy = null;
|
||||
|
|
@ -96,7 +96,7 @@ public class UserCompanyAccessAggregate : AggregateRoot<UserCompanyAccessAggrega
|
|||
public void Apply(UserCompanyAccessRevokedEvent e)
|
||||
{
|
||||
IsActive = false;
|
||||
RevokedAt = DateTimeOffset.UtcNow;
|
||||
RevokedAt = e.RevokedAt;
|
||||
RevokedBy = e.RevokedBy;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,19 @@ public class AttachmentRepository(NpgsqlDataSource dataSource) : IAttachmentRepo
|
|||
return await connection.QuerySingleOrDefaultAsync<AttachmentReadModelDto>(sql, new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<AttachmentReadModelDto?> GetByStoragePathAsync(string storagePath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
|
||||
var sql = $"""
|
||||
SELECT {SelectColumns}
|
||||
FROM attachment_read_models
|
||||
WHERE storage_path = @StoragePath AND is_deleted = FALSE
|
||||
""";
|
||||
|
||||
return await connection.QuerySingleOrDefaultAsync<AttachmentReadModelDto>(sql, new { StoragePath = storagePath });
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AttachmentReadModelDto>> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace Books.Api.EventFlow.Repositories;
|
|||
public interface IAttachmentRepository
|
||||
{
|
||||
Task<AttachmentReadModelDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default);
|
||||
Task<AttachmentReadModelDto?> GetByStoragePathAsync(string storagePath, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<AttachmentReadModelDto>> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<AttachmentReadModelDto>> GetByDraftIdAsync(string draftId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<AttachmentReadModelDto>> GetByTransactionIdAsync(string transactionId, CancellationToken cancellationToken = default);
|
||||
|
|
|
|||
|
|
@ -190,6 +190,15 @@ public static class StandardDanishAccounts
|
|||
yield return new("7460", "Diverse inkl. moms", AccountType.Expense, null, "I25", "2110");
|
||||
yield return new("7480", "Diverse ekskl. moms", AccountType.Expense, null, null, "2110");
|
||||
|
||||
// =========================================
|
||||
// MOMSKONTI (VAT Accounts) - 56xx
|
||||
// Standard: 5610 = Købsmoms, 5611 = Salgsmoms, 5620 = EU-erhvervelsesmoms
|
||||
// Required for VAT calculation and reporting
|
||||
// =========================================
|
||||
yield return new("5610", "Købsmoms", AccountType.Liability, "Indgående moms (Input VAT)", null, "5610", true);
|
||||
yield return new("5611", "Salgsmoms", AccountType.Liability, "Udgående moms (Output VAT)", null, "5611", true);
|
||||
yield return new("5620", "EU-erhvervelsesmoms", AccountType.Liability, "EU acquisition VAT", null, "5620", true);
|
||||
|
||||
// =========================================
|
||||
// PASSIVER - SKYLDIG SKAT OG MOMS (Tax Liabilities) - 79xx
|
||||
// Standard: 7680 = Anden gæld til SKAT, 7920 = A-skat
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using Books.Api.Domain;
|
||||
using Books.Api.EventFlow.ReadModels;
|
||||
using Books.Api.EventFlow.Repositories;
|
||||
using Books.Api.Saft.Models;
|
||||
|
|
@ -294,6 +295,9 @@ public class SaftExportService(
|
|||
if (isDebit) totalDebit += entry.Amount;
|
||||
else totalCredit += entry.Amount;
|
||||
|
||||
// Try to extract VAT information from the entry description
|
||||
var taxInfo = ExtractTaxInformation(entry.Description, entry.Amount);
|
||||
|
||||
return new SaftTransactionLine(
|
||||
(idx + 1).ToString(),
|
||||
accountNumber ?? entry.AccountId.ToString(),
|
||||
|
|
@ -302,7 +306,7 @@ public class SaftExportService(
|
|||
creditAmount,
|
||||
null, // CustomerID - could parse from reference
|
||||
null, // SupplierID
|
||||
null); // TaxInfo - would need VAT code tracking
|
||||
taxInfo);
|
||||
}).ToList();
|
||||
|
||||
transactions.Add(new SaftTransaction(
|
||||
|
|
@ -385,6 +389,37 @@ public class SaftExportService(
|
|||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts VAT/tax information from a ledger entry description.
|
||||
/// VAT lines generated by the system contain patterns like "Moms U25 (25%)" or "Moms I25 (25%)".
|
||||
/// </summary>
|
||||
private static SaftTaxInformation? ExtractTaxInformation(string? description, decimal amount)
|
||||
{
|
||||
if (string.IsNullOrEmpty(description))
|
||||
return null;
|
||||
|
||||
// Check for known VAT codes in the description
|
||||
foreach (var vatCodeInfo in VatCodes.All)
|
||||
{
|
||||
if (vatCodeInfo.Code == VatCodes.INGEN)
|
||||
continue;
|
||||
|
||||
if (description.Contains(vatCodeInfo.Code, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var rate = VatCodes.GetRate(vatCodeInfo.Code);
|
||||
var taxBase = rate > 0 ? Math.Round(amount / rate, 2, MidpointRounding.AwayFromZero) : 0m;
|
||||
|
||||
return new SaftTaxInformation(
|
||||
vatCodeInfo.Code,
|
||||
rate * 100, // TaxPercentage as whole number (25 not 0.25)
|
||||
taxBase,
|
||||
amount);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a Danish CVR number.
|
||||
/// A valid CVR is exactly 8 digits.
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ public class SaftXmlBuilder
|
|||
writer.WriteStartElement("Header");
|
||||
|
||||
writer.WriteElementString("AuditFileVersion", header.AuditFileVersion);
|
||||
writer.WriteElementString("AuditFileCountry", "DK");
|
||||
writer.WriteElementString("AuditFileDateCreated", header.AuditFileDateCreated);
|
||||
writer.WriteElementString("SoftwareCompanyName", header.SoftwareCompanyName);
|
||||
writer.WriteElementString("SoftwareID", header.SoftwareID);
|
||||
|
|
@ -72,6 +73,12 @@ public class SaftXmlBuilder
|
|||
WriteContact(writer, company.Contact);
|
||||
}
|
||||
|
||||
// SAF-T DK requires TaxRegistrationNumber with "DK" prefix + CVR
|
||||
if (!string.IsNullOrEmpty(company.RegistrationNumber))
|
||||
{
|
||||
writer.WriteElementString("TaxRegistrationNumber", "DK" + company.RegistrationNumber);
|
||||
}
|
||||
|
||||
writer.WriteEndElement(); // Company
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
namespace Books.Api;
|
||||
|
||||
public class WeatherForecast
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
|
||||
public int TemperatureC { get; set; }
|
||||
|
||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
||||
|
||||
public string? Summary { get; set; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue