Add function to retrieve company address as JSON and update invoice procedures

- Created a new function `fds__getCompanyAddressJson` to return a company's postal address as a structured JSON object.
- Modified stored procedures `fds__createInvoice`, `fds__setInvoice`, and `fds__prepInvoice` to include a new parameter `@SendToAddressJson` for handling the address data.
- Updated the invoice table and user-defined types to accommodate the new `SendToAddressJson` field.
- Ensured that the address data is properly retrieved and stored in the invoice records.
This commit is contained in:
2026-07-18 18:01:24 +02:00
parent 5ccd85c38f
commit 628802db19
45 changed files with 1892 additions and 26 deletions
+123
View File
@@ -0,0 +1,123 @@
using System.Collections.Generic;
using System.Text;
using eRechnungLib.Model.CodeLists;
using eRechnungLib.Profiles;
using Fuchs.intranet;
using Fuchs.Services;
using Newtonsoft.Json.Linq;
using Xunit;
using static OCORE.OCORE_dictionaries;
namespace Fuchs.Tests;
/// <summary>
/// Verifies the FdsInvoiceData → EN 16931 model mapping and the ZUGFeRD (EN 16931) hybrid
/// production: structured buyer, lines/totals, effortless B2C, and §13b reverse charge. See
/// ADR 0012.
/// </summary>
public class ERechnungMapperTests
{
private static FdsInvoiceData BuildInvoice(string sendToAddressJson, string vat = "19",
string invoiceOptions = "", bool withItem = true)
{
var items = withItem
? "[{'id':'900','type':'material','title':'Reparatur','desc':'Vor Ort','qty':2,'price_net':50,'total_net':100,'vat':'" + vat + "'}]"
: "[]";
var jobj = JObject.Parse("{'req':[{'Id':'1','text':'Auftrag','items':" + items + "}]}");
var inv = new FdsInvoiceData(jobj)
{
InvoiceRegistration = new GenericObjectDictionary(new Dictionary<string, object>
{
["Id"] = "42",
["InvoiceId"] = "R2026-0007",
["InvoiceTitle"] = "Rechnung",
["DateCreated"] = "2026-07-17 10:00:00",
["InvoiceOptions"] = invoiceOptions,
["InvoiceBalance_net"] = "100",
["InvoiceVAT_1"] = vat,
["SendToAddressJson"] = sendToAddressJson,
})
};
return inv;
}
private const string B2BAddress =
"{'name':'Muster GmbH','street':'Hauptstr. 1','postalCode':'40223','city':'Düsseldorf','countryCode':'DE','vatId':'DE123456789'}";
[Fact]
public void BuildEInvoice_MapsSellerBuyerAndLines_WithConsistentTotals()
{
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress)).Model;
Assert.Equal("Sebastian Fuchs GmbH & Co. KG", model.Seller.Name);
Assert.Equal("DE", model.Seller.Address.Country.Value);
Assert.Equal("Muster GmbH", model.Buyer.Name);
Assert.Equal("40223", model.Buyer.Address.PostalCode);
Assert.Equal("DE123456789", model.Buyer.VatId);
Assert.Equal("R2026-0007", model.InvoiceNumber);
var line = Assert.Single(model.Lines);
Assert.Equal(100m, line.NetAmount);
Assert.Equal(VatCategoryCode.StandardRate, line.VatCategory);
Assert.NotNull(model.Totals);
Assert.Equal(100m, model.Totals!.TaxExclusiveAmount);
Assert.Equal(19m, model.Totals.TaxTotalAmount);
Assert.Equal(119m, model.Totals.TaxInclusiveAmount);
}
[Fact]
public void ToZugferd_EN16931_ProducesHybridPdfWithEmbeddedCii()
{
var einvoice = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress));
var result = einvoice.ToZugferd(ZugferdProfile.EN16931);
Assert.True(result.Success);
Assert.StartsWith("%PDF", Encoding.ASCII.GetString(result.Value!, 0, 4));
// The CII XML is embedded and carries the invoice number.
string content = Encoding.Latin1.GetString(result.Value!);
Assert.Contains("CrossIndustryInvoice", content);
// No PDFA-ICC warning: the bundled sRGB profile is present, so the output intent is set.
Assert.DoesNotContain(result.Validation.Warnings, m => m.RuleId == "PDFA-ICC");
}
[Fact]
public void PrivatePerson_NoVatId_MapsWithoutBuyerTaxRegistration_AndProducesHybrid()
{
var b2c = "{'name':'Max Mustermann','street':'Weg 2','postalCode':'50667','city':'Köln','countryCode':'DE'}";
var einvoice = ERechnungMapper.BuildEInvoice(BuildInvoice(b2c));
Assert.Null(einvoice.Model.Buyer.VatId);
var result = einvoice.ToZugferd(ZugferdProfile.EN16931);
Assert.True(result.Success);
Assert.StartsWith("%PDF", Encoding.ASCII.GetString(result.Value!, 0, 4));
}
[Fact]
public void ReverseCharge_13b_SetsCategoryAeAndExemptionReason()
{
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress, vat: "0", invoiceOptions: "§13b")).Model;
var line = Assert.Single(model.Lines);
Assert.Equal(VatCategoryCode.ReverseCharge, line.VatCategory);
Assert.Equal(0m, line.VatRate);
Assert.True(model.VatExemptionReasons.ContainsKey(VatCategoryCode.ReverseCharge));
Assert.Equal(0m, model.Totals!.TaxTotalAmount);
}
[Fact]
public void LumpSumInvoice_NoItems_SynthesisesSingleLineFromTotal()
{
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress, withItem: false)).Model;
var line = Assert.Single(model.Lines);
Assert.Equal(100m, line.NetAmount);
Assert.Equal(119m, model.Totals!.TaxInclusiveAmount);
}
[Theory]
[InlineData("DE", "DE")]
[InlineData("Deutschland", "DE")]
[InlineData("Österreich", "AT")]
[InlineData("", "DE")]
public void NormalizeCountry_MapsNamesAndCodes(string raw, string expected)
=> Assert.Equal(expected, ERechnungMapper.NormalizeCountry(raw).Value);
}
+31
View File
@@ -84,6 +84,37 @@ public class InvoiceDraftServiceTests
Assert.Equal(1, h.Version);
}
[Fact]
public void ApplyPatch_StructuredAddress_ComposesFreeTextAndPersistsJson()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var addr = JObject.Parse(@"{'name':'Muster GmbH','street':'Weg 1','postalCode':'40223','city':'Düsseldorf','countryCode':'DE','vatId':'DE123456789'}");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "address", Value = addr });
Assert.NotNull(s2);
// Free-text block is composed for the PDF / SendToAddress path.
Assert.Equal("Muster GmbH\nWeg 1\n40223 Düsseldorf", s2!.New["invoiceaddress"]!.Value<string>());
// Structured JSON rides inside the CustomValues blob (interim persistence).
var parsed = InvoiceRecipientAddress.FromCustomValues(s2.New["CustomValues"]!.Value<string>());
Assert.NotNull(parsed);
Assert.Equal("DE123456789", parsed!.VatId);
Assert.True(parsed.IsEn16931Conformant);
}
[Fact]
public void ApplyPatch_Address_PlainString_KeepsLegacyFreeTextBehavior()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "address", Value = JToken.FromObject("Weg 9\n50667 Köln") });
Assert.Equal("Weg 9\n50667 Köln", s2!.New["invoiceaddress"]!.Value<string>());
Assert.Null(InvoiceRecipientAddress.FromCustomValues(s2.New["CustomValues"]?.Value<string>()));
}
[Fact]
public void ApplyPatch_BlockReplace_RecomputesTotals()
{
+104
View File
@@ -0,0 +1,104 @@
using Fuchs.Services;
using Newtonsoft.Json.Linq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Pure-logic tests for the structured invoice recipient address: free-text composition for the
/// PDF, EN 16931 / DATEV conformity detection, effortless B2C (no VAT id), and JSON round-tripping
/// through the <c>CustomValues</c> blob. See ADR 0012.
/// </summary>
public class InvoiceRecipientAddressTests
{
private static InvoiceRecipientAddress FullB2B() => new()
{
Name = "Muster GmbH",
Contact = "Frau Schmidt",
Street = "Hauptstraße 1",
PostalCode = "40223",
City = "Düsseldorf",
CountryCode = "DE",
VatId = "DE123456789",
};
[Fact]
public void Compose_DomesticFullAddress_OmitsCountryLineAndVatId()
{
var text = FullB2B().Compose();
Assert.Equal("Muster GmbH\nz.Hd. Frau Schmidt\nHauptstraße 1\n40223 Düsseldorf", text);
Assert.DoesNotContain("DE123456789", text); // VAT id never in the postal block
Assert.DoesNotContain("\nDE", text); // domestic country code suppressed
}
[Fact]
public void Compose_ForeignCountry_AppendsCountryCodeLine()
{
var addr = FullB2B();
addr.CountryCode = "AT";
addr.City = "Wien";
addr.PostalCode = "1010";
Assert.EndsWith("1010 Wien\nAT", addr.Compose());
}
[Fact]
public void PrivatePerson_NoVatId_IsB2CAndStillEn16931Conformant()
{
var addr = new InvoiceRecipientAddress
{
Name = "Max Mustermann",
Street = "Weg 2",
PostalCode = "50667",
City = "Köln",
CountryCode = "DE",
// no VatId
};
Assert.True(addr.IsPrivatePerson);
Assert.True(addr.IsEn16931Conformant);
Assert.Empty(addr.MissingForEn16931());
}
[Theory]
[InlineData("", "DE", "Köln", new[] { "Name" })]
[InlineData("Firma", "", "Köln", new[] { "Land" })]
[InlineData("Firma", "DE", "", new[] { "Ort/PLZ" })]
public void MissingForEn16931_FlagsMandatoryGaps(string name, string country, string city, string[] expected)
{
var addr = new InvoiceRecipientAddress { Name = name, CountryCode = country, City = city };
Assert.Equal(expected, addr.MissingForEn16931());
Assert.False(addr.IsEn16931Conformant);
}
[Fact]
public void FromJson_AcceptsAlternateKeys()
{
var addr = InvoiceRecipientAddress.FromJson(JObject.Parse(
@"{'name':'X','plz':'12345','ort':'Ort','country':'de','ustid':'DE9'}"));
Assert.Equal("12345", addr.PostalCode);
Assert.Equal("Ort", addr.City);
Assert.Equal("DE", addr.CountryCode); // uppercased
Assert.Equal("DE9", addr.VatId);
}
[Fact]
public void FromCustomValues_RoundTripsThroughTheBlob()
{
var cv = new JObject
{
["contactName"] = "someone",
[InvoiceRecipientAddress.CustomValuesKey] = FullB2B().ToJson(),
};
var parsed = InvoiceRecipientAddress.FromCustomValues(cv.ToString());
Assert.NotNull(parsed);
Assert.Equal("Muster GmbH", parsed!.Name);
Assert.Equal("DE123456789", parsed.VatId);
}
[Fact]
public void FromCustomValues_ReturnsNull_WhenNoStructuredAddressPresent()
{
Assert.Null(InvoiceRecipientAddress.FromCustomValues(@"{'contactName':'x'}"));
Assert.Null(InvoiceRecipientAddress.FromCustomValues(""));
Assert.Null(InvoiceRecipientAddress.FromCustomValues("not json"));
}
}