This commit includes all previously untracked backend files: Domain: - Accounts, Attachments, BankConnections, Customers - FiscalYears, Invoices, JournalEntryDrafts - Orders, Products, UserAccess Commands & Handlers: - Full CQRS command structure for all domains Repositories: - PostgreSQL repositories for all read models - Bank transaction and ledger repositories GraphQL: - Input types, scalars, and types for all entities - Mutations and queries Infrastructure: - Banking integration (Enable Banking client) - File storage, Invoicing, Reporting, SAF-T export - Database migrations (003-029) Tests: - Integration tests for GraphQL endpoints - Domain tests - Invoicing and reporting tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
368 lines
11 KiB
C#
368 lines
11 KiB
C#
using AutoFixture;
|
|
using AutoFixture.AutoMoq;
|
|
using AwesomeAssertions;
|
|
using Books.Api.EventFlow.ReadModels;
|
|
using Books.Api.EventFlow.Repositories;
|
|
using Books.Api.Reporting;
|
|
using Ledger.Core.Models;
|
|
using Ledger.Core.Services;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Moq;
|
|
|
|
namespace Books.Api.Tests.Reporting;
|
|
|
|
/// <summary>
|
|
/// Unit tests for VatReportService.
|
|
/// Tests VAT report generation from ledger data for SKAT compliance.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public class VatReportServiceTests
|
|
{
|
|
private readonly IFixture _fixture;
|
|
private readonly Mock<IAccountRepository> _accountRepository;
|
|
private readonly Mock<ILedgerService> _ledgerService;
|
|
private readonly VatReportService _sut;
|
|
|
|
private const string CompanyId = "company-123";
|
|
private const string InputVatAccountNumber = "5610";
|
|
private const string OutputVatAccountNumber = "5611";
|
|
|
|
public VatReportServiceTests()
|
|
{
|
|
_fixture = new Fixture().Customize(new AutoMoqCustomization());
|
|
_accountRepository = new Mock<IAccountRepository>();
|
|
_ledgerService = new Mock<ILedgerService>();
|
|
_sut = new VatReportService(
|
|
_accountRepository.Object,
|
|
_ledgerService.Object,
|
|
NullLogger<VatReportService>.Instance);
|
|
}
|
|
|
|
#region Period Validation Tests
|
|
|
|
[Fact]
|
|
public async Task GenerateReportAsync_PeriodEndBeforeStart_ThrowsArgumentException()
|
|
{
|
|
// Arrange
|
|
var periodStart = new DateOnly(2024, 3, 1);
|
|
var periodEnd = new DateOnly(2024, 2, 1);
|
|
|
|
// Act
|
|
var act = () => _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd);
|
|
|
|
// Assert
|
|
await act.Should().ThrowAsync<ArgumentException>()
|
|
.WithMessage("*startdato*");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GenerateReportAsync_PeriodExceedsOneYear_ThrowsArgumentException()
|
|
{
|
|
// Arrange
|
|
var periodStart = new DateOnly(2024, 1, 1);
|
|
var periodEnd = new DateOnly(2025, 2, 1); // 397 days
|
|
|
|
// Act
|
|
var act = () => _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd);
|
|
|
|
// Assert
|
|
await act.Should().ThrowAsync<ArgumentException>()
|
|
.WithMessage("*366*");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GenerateReportAsync_ValidPeriod_DoesNotThrow()
|
|
{
|
|
// Arrange
|
|
var periodStart = new DateOnly(2024, 1, 1);
|
|
var periodEnd = new DateOnly(2024, 12, 31); // 365 days - valid
|
|
|
|
SetupNoVatAccounts();
|
|
SetupEmptyLedgerResponse();
|
|
|
|
// Act
|
|
var result = await _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd);
|
|
|
|
// Assert - should not throw, returns empty report
|
|
result.Should().NotBeNull();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region No VAT Accounts Tests
|
|
|
|
[Fact]
|
|
public async Task GenerateReportAsync_NoVatAccounts_ReturnsEmptyReport()
|
|
{
|
|
// Arrange
|
|
var periodStart = new DateOnly(2024, 1, 1);
|
|
var periodEnd = new DateOnly(2024, 3, 31);
|
|
|
|
SetupNoVatAccounts();
|
|
|
|
// Act
|
|
var result = await _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd);
|
|
|
|
// Assert
|
|
result.BoxA.Should().Be(0);
|
|
result.BoxB.Should().Be(0);
|
|
result.BoxC.Should().Be(0);
|
|
result.BoxD.Should().Be(0);
|
|
result.NetVat.Should().Be(0);
|
|
result.TransactionCount.Should().Be(0);
|
|
result.PeriodStart.Should().Be(periodStart);
|
|
result.PeriodEnd.Should().Be(periodEnd);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Output VAT Tests
|
|
|
|
[Fact]
|
|
public async Task GenerateReportAsync_OnlyOutputVat_ReturnsCorrectBoxA()
|
|
{
|
|
// Arrange
|
|
var periodStart = new DateOnly(2024, 1, 1);
|
|
var periodEnd = new DateOnly(2024, 3, 31);
|
|
var outputGuid = Guid.NewGuid();
|
|
|
|
SetupInputVatAccount(null);
|
|
SetupOutputVatAccount(outputGuid, "Salgsmoms");
|
|
SetupLedgerResponse(new AccountPeriodBalance
|
|
{
|
|
AccountId = outputGuid,
|
|
TotalCredits = 25000m,
|
|
TotalDebits = 0m,
|
|
NetChange = -25000m,
|
|
EntryCount = 50,
|
|
Currency = "DKK"
|
|
});
|
|
|
|
// Act
|
|
var result = await _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd);
|
|
|
|
// Assert
|
|
result.BoxA.Should().Be(25000m);
|
|
result.BoxB.Should().Be(0);
|
|
result.TotalOutputVat.Should().Be(25000m);
|
|
result.TotalInputVat.Should().Be(0);
|
|
result.NetVat.Should().Be(25000m);
|
|
result.TransactionCount.Should().Be(50);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Input VAT Tests
|
|
|
|
[Fact]
|
|
public async Task GenerateReportAsync_OnlyInputVat_ReturnsCorrectBoxB()
|
|
{
|
|
// Arrange
|
|
var periodStart = new DateOnly(2024, 1, 1);
|
|
var periodEnd = new DateOnly(2024, 3, 31);
|
|
var inputGuid = Guid.NewGuid();
|
|
|
|
SetupInputVatAccount(inputGuid, "Købsmoms");
|
|
SetupOutputVatAccount(null);
|
|
SetupLedgerResponse(new AccountPeriodBalance
|
|
{
|
|
AccountId = inputGuid,
|
|
TotalDebits = 15000m,
|
|
TotalCredits = 0m,
|
|
NetChange = 15000m,
|
|
EntryCount = 30,
|
|
Currency = "DKK"
|
|
});
|
|
|
|
// Act
|
|
var result = await _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd);
|
|
|
|
// Assert
|
|
result.BoxA.Should().Be(0);
|
|
result.BoxB.Should().Be(15000m);
|
|
result.TotalOutputVat.Should().Be(0);
|
|
result.TotalInputVat.Should().Be(15000m);
|
|
result.NetVat.Should().Be(-15000m); // Negative = refund
|
|
result.TransactionCount.Should().Be(30);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Mixed VAT Tests
|
|
|
|
[Fact]
|
|
public async Task GenerateReportAsync_BothInputAndOutputVat_CalculatesNetCorrectly()
|
|
{
|
|
// Arrange
|
|
var periodStart = new DateOnly(2024, 1, 1);
|
|
var periodEnd = new DateOnly(2024, 3, 31);
|
|
var inputGuid = Guid.NewGuid();
|
|
var outputGuid = Guid.NewGuid();
|
|
|
|
SetupInputVatAccount(inputGuid, "Købsmoms");
|
|
SetupOutputVatAccount(outputGuid, "Salgsmoms");
|
|
SetupLedgerResponse(
|
|
new AccountPeriodBalance
|
|
{
|
|
AccountId = inputGuid,
|
|
TotalDebits = 10000m,
|
|
TotalCredits = 0m,
|
|
NetChange = 10000m,
|
|
EntryCount = 20,
|
|
Currency = "DKK"
|
|
},
|
|
new AccountPeriodBalance
|
|
{
|
|
AccountId = outputGuid,
|
|
TotalCredits = 25000m,
|
|
TotalDebits = 0m,
|
|
NetChange = -25000m,
|
|
EntryCount = 40,
|
|
Currency = "DKK"
|
|
});
|
|
|
|
// Act
|
|
var result = await _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd);
|
|
|
|
// Assert
|
|
result.BoxA.Should().Be(25000m);
|
|
result.BoxB.Should().Be(10000m);
|
|
result.TotalOutputVat.Should().Be(25000m);
|
|
result.TotalInputVat.Should().Be(10000m);
|
|
result.NetVat.Should().Be(15000m); // 25000 - 10000 = to pay
|
|
result.TransactionCount.Should().Be(60);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Basis Calculation Tests
|
|
|
|
[Fact]
|
|
public async Task GenerateReportAsync_WithOutputVat_CalculatesBasis1From25PercentRate()
|
|
{
|
|
// Arrange
|
|
var periodStart = new DateOnly(2024, 1, 1);
|
|
var periodEnd = new DateOnly(2024, 3, 31);
|
|
var outputGuid = Guid.NewGuid();
|
|
|
|
SetupInputVatAccount(null);
|
|
SetupOutputVatAccount(outputGuid, "Salgsmoms");
|
|
SetupLedgerResponse(new AccountPeriodBalance
|
|
{
|
|
AccountId = outputGuid,
|
|
TotalCredits = 2500m, // 25% VAT on 10000
|
|
TotalDebits = 0m,
|
|
NetChange = -2500m,
|
|
EntryCount = 10,
|
|
Currency = "DKK"
|
|
});
|
|
|
|
// Act
|
|
var result = await _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd);
|
|
|
|
// Assert
|
|
result.BoxA.Should().Be(2500m);
|
|
result.Basis1.Should().Be(10000m); // 2500 / 0.25 = 10000
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Empty Period Tests
|
|
|
|
[Fact]
|
|
public async Task GenerateReportAsync_EmptyPeriod_ReturnsZeroAmounts()
|
|
{
|
|
// Arrange
|
|
var periodStart = new DateOnly(2024, 1, 1);
|
|
var periodEnd = new DateOnly(2024, 3, 31);
|
|
var inputGuid = Guid.NewGuid();
|
|
var outputGuid = Guid.NewGuid();
|
|
|
|
SetupInputVatAccount(inputGuid, "Købsmoms");
|
|
SetupOutputVatAccount(outputGuid, "Salgsmoms");
|
|
SetupEmptyLedgerResponse();
|
|
|
|
// Act
|
|
var result = await _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd);
|
|
|
|
// Assert
|
|
result.BoxA.Should().Be(0);
|
|
result.BoxB.Should().Be(0);
|
|
result.TransactionCount.Should().Be(0);
|
|
result.NetVat.Should().Be(0);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Helper Methods
|
|
|
|
private void SetupNoVatAccounts()
|
|
{
|
|
_accountRepository
|
|
.Setup(x => x.GetByCompanyAndNumberAsync(
|
|
It.IsAny<string>(),
|
|
It.IsAny<string>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((AccountReadModelDto?)null);
|
|
}
|
|
|
|
private void SetupEmptyLedgerResponse()
|
|
{
|
|
_ledgerService
|
|
.Setup(x => x.QueryEntriesAsync(
|
|
It.IsAny<EntriesQuery>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new EntriesQueryResult { Aggregates = [] });
|
|
}
|
|
|
|
private void SetupLedgerResponse(params AccountPeriodBalance[] balances)
|
|
{
|
|
_ledgerService
|
|
.Setup(x => x.QueryEntriesAsync(
|
|
It.IsAny<EntriesQuery>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new EntriesQueryResult { Aggregates = balances.ToList() });
|
|
}
|
|
|
|
private void SetupInputVatAccount(Guid? accountGuid, string name = "Købsmoms")
|
|
{
|
|
var account = accountGuid.HasValue
|
|
? new AccountReadModelDto
|
|
{
|
|
Id = $"account-{accountGuid.Value}",
|
|
CompanyId = CompanyId,
|
|
AccountNumber = InputVatAccountNumber,
|
|
Name = name
|
|
}
|
|
: null;
|
|
|
|
_accountRepository
|
|
.Setup(x => x.GetByCompanyAndNumberAsync(
|
|
CompanyId,
|
|
InputVatAccountNumber,
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(account);
|
|
}
|
|
|
|
private void SetupOutputVatAccount(Guid? accountGuid, string name = "Salgsmoms")
|
|
{
|
|
var account = accountGuid.HasValue
|
|
? new AccountReadModelDto
|
|
{
|
|
Id = $"account-{accountGuid.Value}",
|
|
CompanyId = CompanyId,
|
|
AccountNumber = OutputVatAccountNumber,
|
|
Name = name
|
|
}
|
|
: null;
|
|
|
|
_accountRepository
|
|
.Setup(x => x.GetByCompanyAndNumberAsync(
|
|
CompanyId,
|
|
OutputVatAccountNumber,
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(account);
|
|
}
|
|
|
|
#endregion
|
|
}
|