using System.Linq; using Centron.BusinessLogic.DataExchange.EDI; using Centron.BusinessLogic.DataExchange.EDI.SaleInvoices; using Centron.Data.Entities.Sales.Receipts.Invoices; using Centron.Interfaces.DataExchange.BookKeeping; using JetBrains.Annotations; namespace Centron.Tests.BL.DataExchange.EDI.SaleInvoices; /// /// Tests for the contract-contingent balance merge in . /// Service and compensation items share a ; the merge folds the /// compensation amount into the service item and removes the compensation line from the export. /// On invoices the service item is positive and the compensation is negative; credit vouchers store the same /// pair with inverted signs (Ticket 164110 — without the sign-aware merge the wrong line would be removed /// and the position total would no longer match the receipt header). /// [TestSubject(typeof(InvoiceZugferdBL))] public class InvoiceZugferdBalanceMergeTests { private const string BalanceA = "BAL-A"; private const string BalanceB = "BAL-B"; private static ZugferdExportPositionItem Service(string balanceId, decimal netTotal, decimal netUnit, decimal quantity) => new() { BalanceID = balanceId, NetPriceTotalFC = netTotal, NetPrice = netUnit, Quantity = quantity, TaxPriceTotalFC = 0m, ArticleCode = "SERPELISTD", Text = "Servicedienstleistung", }; private static ZugferdExportPositionItem Compensation(string balanceId, decimal netTotal) => new() { BalanceID = balanceId, NetPriceTotalFC = netTotal, NetPrice = -netTotal, Quantity = 0m, TaxPriceTotalFC = 0m, ArticleCode = "AUSGLEICH", Text = "Ausgleichsartikel für Kontingente", }; private static ZugferdExportItem ItemWith(params ZugferdExportPositionItem[] positions) => new() { Positions = positions.ToList() }; [Fact] public void Invoice_FullyCoveredByContingent_ReducesServiceToZeroAndKeepsBasisQuantity() { var service = Service(BalanceA, netTotal: 150m, netUnit: 150m, quantity: 1m); var compensation = Compensation(BalanceA, netTotal: -150m); var export = ItemWith(service, compensation); InvoiceZugferdBL.MergeBalanceCompensationItems(export, BookKeepingReceiptKind.Invoice); Assert.Single(export.Positions); Assert.Same(service, export.Positions[0]); Assert.Equal(0m, service.NetPriceTotalFC); Assert.Equal(0m, service.Quantity); Assert.Equal(1m, service.BasisQuantity); } [Fact] public void Invoice_PartiallyCovered_ReducesServiceProportionally() { var service = Service(BalanceA, netTotal: 150m, netUnit: 150m, quantity: 1m); var compensation = Compensation(BalanceA, netTotal: -90m); var export = ItemWith(service, compensation); InvoiceZugferdBL.MergeBalanceCompensationItems(export, BookKeepingReceiptKind.Invoice); Assert.Single(export.Positions); Assert.Equal(60m, service.NetPriceTotalFC); Assert.Equal(0.4m, service.Quantity); Assert.Equal(1m, service.BasisQuantity); } [Fact] public void CreditVoucher_FullyCoveredByContingent_ReducesServiceToZeroAndKeepsRealServiceLine() { // Credit voucher: signs are inverted compared to the source invoice var service = Service(BalanceA, netTotal: -150m, netUnit: -150m, quantity: 1m); var compensation = Compensation(BalanceA, netTotal: 150m); var export = ItemWith(service, compensation); InvoiceZugferdBL.MergeBalanceCompensationItems(export, BookKeepingReceiptKind.CreditVoucher); Assert.Single(export.Positions); Assert.Same(service, export.Positions[0]); Assert.Equal("SERPELISTD", export.Positions[0].ArticleCode); Assert.Equal(0m, service.NetPriceTotalFC); Assert.Equal(0m, service.Quantity); Assert.Equal(1m, service.BasisQuantity); } [Fact] public void CreditVoucher_PartiallyCovered_ReducesServiceProportionally() { var service = Service(BalanceA, netTotal: -150m, netUnit: -150m, quantity: 1m); var compensation = Compensation(BalanceA, netTotal: 90m); var export = ItemWith(service, compensation); InvoiceZugferdBL.MergeBalanceCompensationItems(export, BookKeepingReceiptKind.CreditVoucher); Assert.Single(export.Positions); Assert.Equal(-60m, service.NetPriceTotalFC); Assert.Equal(0.4m, service.Quantity); Assert.Equal(1m, service.BasisQuantity); } [Fact] public void MultipleBalanceGroups_AreMergedIndependently() { var service1 = Service(BalanceA, netTotal: 150m, netUnit: 150m, quantity: 1m); var compensation1 = Compensation(BalanceA, netTotal: -150m); var service2 = Service(BalanceB, netTotal: 200m, netUnit: 100m, quantity: 2m); var compensation2 = Compensation(BalanceB, netTotal: -100m); var export = ItemWith(service1, compensation1, service2, compensation2); InvoiceZugferdBL.MergeBalanceCompensationItems(export, BookKeepingReceiptKind.Invoice); Assert.Equal(2, export.Positions.Count); Assert.Equal(0m, service1.NetPriceTotalFC); Assert.Equal(0m, service1.Quantity); Assert.Equal(100m, service2.NetPriceTotalFC); Assert.Equal(1m, service2.Quantity); Assert.DoesNotContain(compensation1, export.Positions); Assert.DoesNotContain(compensation2, export.Positions); } [Fact] public void PositionsWithoutBalanceId_AreLeftUntouched() { var standalone = new ZugferdExportPositionItem { BalanceID = null, NetPriceTotalFC = 99m, NetPrice = 99m, Quantity = 1m, ArticleCode = "OTHER", }; var export = ItemWith(standalone); InvoiceZugferdBL.MergeBalanceCompensationItems(export, BookKeepingReceiptKind.Invoice); Assert.Single(export.Positions); Assert.Same(standalone, export.Positions[0]); Assert.Equal(99m, standalone.NetPriceTotalFC); Assert.Equal(1m, standalone.Quantity); Assert.Null(standalone.BasisQuantity); } [Fact] public void ResolveServicePeriod_ItemPeriodWinsBeforeReceiptHeadAndFallback() { var receipt = new ReceiptInvoice { ServicePeriodFrom = new System.DateTime(2024, 1, 1), ServicePeriodTo = new System.DateTime(2024, 1, 31), }; var item = new ReceiptInvoiceItem { I3D = 10, ServicePeriodFrom = new System.DateTime(2024, 2, 2, 14, 0, 0), ServicePeriodTo = new System.DateTime(2024, 2, 4, 16, 0, 0), }; var fallbackCalled = false; var result = InvoiceZugferdBL.ResolveServicePeriod( receipt, item, BookKeepingReceiptKind.Invoice, (_, _) => { fallbackCalled = true; return (new System.DateTime(2024, 3, 1), new System.DateTime(2024, 3, 31)); }); Assert.Equal(new System.DateTime(2024, 2, 2), result.From); Assert.Equal(new System.DateTime(2024, 2, 4), result.To); Assert.False(fallbackCalled); } [Fact] public void ResolveServicePeriod_ReceiptHeadWinsBeforeFallback() { var receipt = new ReceiptInvoice { ServicePeriodFrom = new System.DateTime(2024, 1, 1, 9, 0, 0), ServicePeriodTo = new System.DateTime(2024, 1, 31, 18, 0, 0), }; var item = new ReceiptInvoiceItem { I3D = 11 }; var fallbackCalled = false; var result = InvoiceZugferdBL.ResolveServicePeriod( receipt, item, BookKeepingReceiptKind.Invoice, (_, _) => { fallbackCalled = true; return (new System.DateTime(2024, 3, 1), new System.DateTime(2024, 3, 31)); }); Assert.Equal(new System.DateTime(2024, 1, 1), result.From); Assert.Equal(new System.DateTime(2024, 1, 31), result.To); Assert.False(fallbackCalled); } [Fact] public void ResolveServicePeriod_UsesContractFallbackWhenItemAndHeadAreEmpty() { var receipt = new ReceiptInvoice(); var item = new ReceiptInvoiceItem { I3D = 12 }; var result = InvoiceZugferdBL.ResolveServicePeriod( receipt, item, BookKeepingReceiptKind.Invoice, (itemI3D, receiptKind) => { Assert.Equal(12, itemI3D); Assert.Equal(BookKeepingReceiptKind.Invoice, receiptKind); return (new System.DateTime(2024, 3, 1), new System.DateTime(2024, 3, 31)); }); Assert.Equal(new System.DateTime(2024, 3, 1), result.From); Assert.Equal(new System.DateTime(2024, 3, 31), result.To); } [Fact] public void ApplyHeaderBillingPeriodFromPositions_UsesMinMaxAndKeepsSameDayPeriod() { var export = ItemWith( new ZugferdExportPositionItem { BillingPeriodFrom = new System.DateTime(2024, 2, 15), BillingPeriodTo = new System.DateTime(2024, 2, 15), }, new ZugferdExportPositionItem { BillingPeriodFrom = new System.DateTime(2024, 1, 1), BillingPeriodTo = new System.DateTime(2024, 3, 31), }); InvoiceZugferdBL.ApplyHeaderBillingPeriodFromPositions(export); Assert.Equal(new System.DateTime(2024, 1, 1), export.BillingPeriodFrom); Assert.Equal(new System.DateTime(2024, 3, 31), export.BillingPeriodTo); } [Fact] public void IsSingleServicePeriodDate_ReturnsTrue_WhenDatesAreEqualIgnoringTime() { var isSingleDate = InvoiceZugferdBL.IsSingleServicePeriodDate( new System.DateTime(2026, 2, 6, 10, 0, 0), new System.DateTime(2026, 2, 6, 13, 30, 0)); Assert.True(isSingleDate); } [Fact] public void GetHeaderDeliveryDate_UsesServicePeriodTo_WhenHeaderPeriodIsSingleDate() { var exportItem = ItemWith(); exportItem.ReceiptDate = new System.DateTime(2026, 2, 1); exportItem.DeliveryDate = new System.DateTime(2026, 2, 4); exportItem.BillingPeriodFrom = new System.DateTime(2026, 2, 6, 10, 0, 0); exportItem.BillingPeriodTo = new System.DateTime(2026, 2, 6, 13, 30, 0); var deliveryDate = InvoiceZugferdBL.GetHeaderDeliveryDate(exportItem); Assert.Equal(new System.DateTime(2026, 2, 6), deliveryDate); } [Fact] public void DoCreateActualDeliverySupplyChainEvent_UsesOnlyDeliveryDateWhenPeriodDatesAreEqual() { var document = new System.Xml.XmlDocument(); var node = InvoiceZugferdBL.DoCreateActualDeliverySupplyChainEvent( new System.DateTime(2026, 2, 6, 13, 30, 0), document); var occurrenceDateTimeNode = Assert.Single(node.ChildNodes.Cast(), f => f.LocalName == "OccurrenceDateTime"); var dateTimeStringNode = Assert.Single(occurrenceDateTimeNode.ChildNodes.Cast(), f => f.LocalName == "DateTimeString"); Assert.Equal("102", dateTimeStringNode.Attributes?["format"]?.Value); Assert.Equal("20260206", dateTimeStringNode.InnerText); } [Fact] public void DoCreateBillingSpecifiedPeriod_UsesStartAndEndDateWhenDatesDiffer() { var document = new System.Xml.XmlDocument(); var node = InvoiceZugferdBL.DoCreateBillingSpecifiedPeriod( new System.DateTime(2026, 2, 6, 10, 0, 0), new System.DateTime(2026, 2, 7, 13, 30, 0), document); var startDateTimeNode = Assert.Single(node.ChildNodes.Cast(), f => f.LocalName == "StartDateTime"); var endDateTimeNode = Assert.Single(node.ChildNodes.Cast(), f => f.LocalName == "EndDateTime"); Assert.Equal("20260206", startDateTimeNode.InnerText); Assert.Equal("20260207", endDateTimeNode.InnerText); } }