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:
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user