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
+227
View File
@@ -0,0 +1,227 @@
using System.Globalization;
using eRechnungLib;
using eRechnungLib.Model;
using eRechnungLib.Model.CodeLists;
using Fuchs.intranet;
using static OCORE.OCORE_dictionaries;
namespace Fuchs.Services;
/// <summary>
/// Maps a Fuchs <see cref="FdsInvoiceData"/> onto the strongly-typed EN 16931 invoice model of
/// <c>eRechnungLib</c>, so it can be emitted as a ZUGFeRD/Factur-X hybrid (see ADR 0005/0012).
/// </summary>
/// <remarks>
/// The seller (<c>BG-4</c>) is Fuchs itself; its master data currently lives as constants in
/// <c>FuchsPdf</c> (letterhead, Steuernummer, bank) and is mirrored here — a future change should
/// lift both into shared configuration. The buyer (<c>BG-7</c>) comes from the structured
/// <see cref="FdsInvoiceData.RecipientAddress"/>; a private person (no VAT id) maps cleanly with
/// no buyer tax registration. Amounts/VAT are recomputed by the library from the mapped lines.
/// </remarks>
public static class ERechnungMapper
{
// Seller master data — mirrors the FuchsPdf letterhead/footer constants (ADR 0005).
private const string SellerName = "Sebastian Fuchs GmbH & Co. KG";
private const string SellerStreet = "Germaniastraße 15";
private const string SellerPostalCode = "40223";
private const string SellerCity = "Düsseldorf";
private const string SellerTaxNumber = "106/5849/2962"; // Steuernummer (BT-32)
private const string SellerIban = "DE76300501100045014800"; // Stadtsparkasse Düsseldorf (GiroCode account)
private const string SellerBic = "DUSSDEDDXXX";
/// <summary>Builds an <see cref="EInvoice"/> (EN 16931 model) from the Fuchs invoice data.</summary>
public static EInvoice BuildEInvoice(FdsInvoiceData invoice)
{
ArgumentNullException.ThrowIfNull(invoice);
var reg = invoice.InvoiceRegistration;
bool reverseCharge = (reg?.getString("InvoiceOptions") ?? "").Contains("§13b", StringComparison.Ordinal);
var model = new Invoice
{
InvoiceNumber = NonEmpty(reg?.getString("InvoiceId"), invoice.Id, "ENTWURF"),
IssueDate = IssueDate(reg),
CurrencyCode = CurrencyCode.Eur,
Seller = BuildSeller(),
Buyer = BuildBuyer(invoice),
Payment = BuildPayment(invoice),
};
string title = reg?.getString("InvoiceTitle") ?? "";
if (!string.IsNullOrWhiteSpace(title))
model.Notes.Add(new InvoiceNote(title));
AddLines(model, invoice, reverseCharge);
if (reverseCharge)
model.VatExemptionReasons[VatCategoryCode.ReverseCharge] =
new VatExemptionReason("Steuerschuldnerschaft des Leistungsempfängers (§ 13b UStG)");
return EInvoice.CreateInvoice(model).Recalculate();
}
private static TradeParty BuildSeller()
{
var seller = new TradeParty
{
Name = SellerName,
Address = new PostalAddress
{
Line1 = SellerStreet,
PostalCode = SellerPostalCode,
City = SellerCity,
Country = CountryCode.Germany,
},
};
seller.TaxRegistrations.Add(new TaxRegistration(SellerTaxNumber, TaxRegistrationScheme.LocalTaxNumber));
return seller;
}
private static TradeParty BuildBuyer(FdsInvoiceData invoice)
{
var addr = invoice.RecipientAddress;
if (addr is null)
{
// No structured address yet (older draft): best-effort from the free-text block so a
// model can still be produced. The country defaults to DE; refine once structured
// capture is in place. EN 16931 validation will flag any remaining gaps.
var lines = (invoice.InvoiceRegistration?.getString("SendToAddress") ?? "")
.Replace("\r\n", "\n").Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToArray();
return new TradeParty
{
Name = lines.Length > 0 ? lines[0] : "Rechnungsempfänger",
Address = new PostalAddress { Country = CountryCode.Germany },
};
}
var buyer = new TradeParty
{
Name = NonEmpty(addr.Name, "Rechnungsempfänger"),
Address = new PostalAddress
{
Line1 = NullIfEmpty(addr.Street),
Line2 = NullIfEmpty(addr.AddressLine2),
PostalCode = NullIfEmpty(addr.PostalCode),
City = NullIfEmpty(addr.City),
Country = NormalizeCountry(addr.CountryCode),
},
};
if (!string.IsNullOrWhiteSpace(addr.VatId))
buyer.TaxRegistrations.Add(new TaxRegistration(addr.VatId.Trim(), TaxRegistrationScheme.Vat));
return buyer;
}
private static PaymentInstructions BuildPayment(FdsInvoiceData invoice)
{
var payment = new PaymentInstructions
{
MeansCode = PaymentMeansCode.SepaCreditTransfer,
RemittanceInformation = invoice.InvoiceRegistration?.getString("InvoiceId") is { Length: > 0 } inv ? inv : null,
};
payment.CreditTransfers.Add(new CreditTransferAccount
{
AccountId = SellerIban,
AccountName = SellerName,
BankId = SellerBic,
});
return payment;
}
private static void AddLines(Invoice model, FdsInvoiceData invoice, bool reverseCharge)
{
int id = 0;
foreach (var item in invoice.InvoiceItems)
{
string name = FirstNonEmpty(Str(item, "title"), Str(item, "nme"), Str(item, "text"), Str(item, "t"));
string desc = Str(item, "desc");
if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(desc)) name = "Position";
decimal qty = Dec(item, "qty", "q", "Quantity") ?? 1m;
if (qty == 0m) qty = 1m;
decimal net = Dec(item, "total_net", "value_total", "vt") ?? 0m;
decimal? unit = Dec(item, "price_net", "value", "v");
decimal rate = Dec(item, "vat") ?? 0m;
id++;
var line = new InvoiceLine
{
Id = id.ToString(CultureInfo.InvariantCulture),
Quantity = qty,
UnitCode = UnitCode.One,
NetPrice = unit ?? (qty != 0 ? decimal.Round(net / qty, 2, MidpointRounding.AwayFromZero) : net),
NetAmount = net,
VatCategory = reverseCharge ? VatCategoryCode.ReverseCharge
: rate > 0 ? VatCategoryCode.StandardRate : VatCategoryCode.ZeroRatedGoods,
VatRate = reverseCharge ? 0m : rate,
Item = new TradeItem { Name = Truncate(name, 500), Description = NullIfEmpty(desc) },
};
model.Lines.Add(line);
}
// EN 16931 requires at least one line (BR-16); synthesise one from the invoice total when
// the item list is empty (e.g. a lump-sum invoice).
if (model.Lines.Count == 0)
{
decimal net = ParseInvariant(invoice.InvoiceRegistration?.getString("InvoiceBalance_net")) ?? 0m;
decimal rate = ParseInvariant(invoice.InvoiceRegistration?.getString("InvoiceVAT_1")) ?? 0m;
model.Lines.Add(new InvoiceLine
{
Id = "1",
Quantity = 1m,
UnitCode = UnitCode.One,
NetPrice = net,
NetAmount = net,
VatCategory = reverseCharge ? VatCategoryCode.ReverseCharge
: rate > 0 ? VatCategoryCode.StandardRate : VatCategoryCode.ZeroRatedGoods,
VatRate = reverseCharge ? 0m : rate,
Item = new TradeItem { Name = NonEmpty(invoice.InvoiceRegistration?.getString("InvoiceTitle"), "Leistung") },
});
}
}
// ── helpers ────────────────────────────────────────────────────────────────
private static DateOnly IssueDate(GenericObjectDictionary? reg)
{
foreach (var key in new[] { "DateFinalized", "DateCreated" })
if (reg?.getString(key) is { Length: > 0 } s && DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt))
return DateOnly.FromDateTime(dt);
return DateOnly.FromDateTime(DateTime.Today);
}
/// <summary>Normalises a raw country value (ISO alpha-2, or a German/English name) to a country code.</summary>
internal static CountryCode NormalizeCountry(string? raw)
{
string s = (raw ?? "").Trim();
if (s.Length == 2 && s.All(char.IsLetter)) return new CountryCode(s);
return s.ToLowerInvariant() switch
{
"deutschland" or "germany" => CountryCode.Germany,
"österreich" or "oesterreich" or "austria" => CountryCode.Austria,
"schweiz" or "switzerland" or "suisse" => CountryCode.Switzerland,
"" => CountryCode.Germany,
_ => CountryCode.Germany,
};
}
private static string Str(IDictionary<string, object?> d, string key)
=> d.TryGetValue(key, out var v) && v != null ? Convert.ToString(v, CultureInfo.InvariantCulture)?.Trim() ?? "" : "";
private static decimal? Dec(IDictionary<string, object?> d, params string[] keys)
{
foreach (var k in keys)
if (d.TryGetValue(k, out var v) && v != null)
{
string s = Convert.ToString(v, CultureInfo.InvariantCulture)?.Replace("%", "").Trim() ?? "";
if (decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var dec)) return dec;
if (decimal.TryParse(s.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out dec)) return dec;
}
return null;
}
private static decimal? ParseInvariant(string? s)
=> decimal.TryParse((s ?? "").Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null;
private static string FirstNonEmpty(params string[] values) => values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "";
private static string NonEmpty(params string?[] values) => values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "";
private static string? NullIfEmpty(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max];
}
+88
View File
@@ -0,0 +1,88 @@
using System.Diagnostics;
using eRechnungLib;
using eRechnungLib.Profiles;
using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Fuchs.Services;
/// <summary>
/// Produces the ZUGFeRD/Factur-X hybrid PDF for a finalized invoice by mapping
/// <see cref="FdsInvoiceData"/> to the EN 16931 model and embedding the CII XML into the
/// FuchsPdf-rendered <b>visual</b> PDF (ADR 0012 — eRechnungLib owns the single PDF/A-3 layer).
/// </summary>
public interface IERechnungService
{
/// <summary>Whether eRechnung emission is switched on (<c>Fuchs:ERechnung:Enabled</c>).</summary>
bool Enabled { get; }
/// <summary>
/// Builds the hybrid PDF from the raw (non-PDF/A) visual PDF, or returns <see langword="null"/>
/// when disabled or on any failure — the caller then falls back to the plain PDF/A so
/// invoicing never breaks because of eRechnung production.
/// </summary>
byte[]? TryBuildHybridPdf(FdsInvoiceData invoice, byte[] rawVisualPdf);
}
/// <inheritdoc cref="IERechnungService"/>
public sealed class ERechnungService : IERechnungService
{
private readonly ERechnungSettings _settings;
private readonly ILogger<ERechnungService> _logger;
public ERechnungService(IOptions<ERechnungSettings> settings, ILogger<ERechnungService> logger)
{
_settings = settings.Value;
_logger = logger;
}
public bool Enabled => _settings.Enabled;
public byte[]? TryBuildHybridPdf(FdsInvoiceData invoice, byte[] rawVisualPdf)
{
ArgumentNullException.ThrowIfNull(invoice);
if (!_settings.Enabled) return null;
if (rawVisualPdf is null || rawVisualPdf.Length == 0) return null;
var sw = Stopwatch.StartNew();
using var act = FuchsTelemetry.StartActivity("invoice.erechnung");
act?.SetTag("fuchs.invoice.id", invoice.Id);
try
{
var profile = ParseProfile(_settings.Profile);
var einvoice = ERechnungMapper.BuildEInvoice(invoice);
var result = einvoice.ToZugferd(profile, rawVisualPdf);
if (!result.Success)
{
_logger.LogError("eRechnung: conversion produced no output for invoice {Id}. Findings: {Findings}",
invoice.Id, result.Validation);
return null;
}
if (!result.Validation.IsValid)
_logger.LogWarning("eRechnung: invoice {Id} produced with validation findings: {Findings}",
invoice.Id, result.Validation);
act?.SetTag("fuchs.erechnung.bytes", result.Value!.Length);
_logger.LogInformation("eRechnung: hybrid PDF/A-3 built for invoice {Id} ({Profile}, {Bytes} bytes, {Ms} ms)",
invoice.Id, profile, result.Value.Length, sw.ElapsedMilliseconds);
return result.Value;
}
catch (Exception ex)
{
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "eRechnung: hybrid build failed for invoice {Id}; falling back to plain PDF/A", invoice.Id);
return null;
}
}
private static ZugferdProfile ParseProfile(string? profile) => (profile ?? "").Trim().ToUpperInvariant() switch
{
"EXTENDED" => ZugferdProfile.Extended,
"BASIC" => ZugferdProfile.Basic,
"XRECHNUNG" => ZugferdProfile.XRechnung,
_ => ZugferdProfile.EN16931,
};
}
+50
View File
@@ -0,0 +1,50 @@
namespace Fuchs.Services;
/// <summary>
/// eRechnung (ZUGFeRD/Factur-X) output settings, bound from appsettings.json → "Fuchs:ERechnung".
/// </summary>
/// <remarks>
/// Per ADR 0005 the invoice visual PDF is produced by <c>FuchsPdf</c> (render-only, no Spire
/// PDF/A) and handed to <c>eRechnungLib.ToZugferd(...)</c>, which owns the single PDF/A-3 layer
/// and embeds the CII XML. This is the gate that turns that emission on and configures it.
/// </remarks>
public sealed class ERechnungSettings
{
/// <summary>
/// When <see langword="true"/>, finalized invoices are emitted as a ZUGFeRD/Factur-X hybrid
/// PDF/A-3. Kept <see langword="false"/> until the <c>FdsInvoiceData</c> → model mapping is
/// wired, so the app keeps producing the plain PDF/A until then.
/// </summary>
public bool Enabled { get; set; }
/// <summary>The ZUGFeRD profile to emit. Defaults to <c>EN16931</c> (the minimum DATEV accepts
/// for full booking; MINIMUM/BASIC WL are intentionally not offered).</summary>
public string Profile { get; set; } = "EN16931";
/// <summary>Formal-conformance validation settings (PDF/A-3 via veraPDF, ZUGFeRD rules).</summary>
public ERechnungValidationSettings Validation { get; set; } = new();
}
/// <summary>
/// Settings for the external eRechnung validation service (veraPDF for PDF/A-3 and a
/// ZUGFeRD/EN 16931 validator), bound from "Fuchs:ERechnung:Validation".
/// </summary>
/// <remarks>
/// The concrete service URL/contract is provided by an online service that is not yet available;
/// this is the configurable seam so only the URL has to be supplied later. While
/// <see cref="ServiceUrl"/> is empty, verification is reported as "not configured / skipped".
/// </remarks>
public sealed class ERechnungValidationSettings
{
/// <summary>Whether to call the external validation service after producing a hybrid PDF.</summary>
public bool Enabled { get; set; }
/// <summary>Base URL of the online veraPDF/ZUGFeRD validation service (empty until provisioned).</summary>
public string ServiceUrl { get; set; } = "";
/// <summary>
/// When <see langword="true"/>, a validation error withholds the invoice output rather than
/// shipping it with findings. Defaults to <see langword="false"/> (report-only).
/// </summary>
public bool FailOnError { get; set; }
}
+23
View File
@@ -143,6 +143,29 @@ public class FuchsPdfService : IPdfService
}
}
public byte[] DocToPdfBytesRaw(Document doc)
{
var sw = Stopwatch.StartNew();
using var act = FuchsTelemetry.StartActivity("pdf.render.raw");
try
{
byte[] bytes = FuchsPdf.DocToPdfBytesRaw(doc);
sw.Stop();
FuchsTelemetry.PdfRenderDuration.Record(sw.Elapsed.TotalMilliseconds,
new KeyValuePair<string, object?>("operation", "pdf-raw"));
act?.SetTag("fuchs.pdf.bytes", bytes.Length);
_logger.LogDebug("DocToPdfBytesRaw rendered {Bytes} bytes in {Ms} ms", bytes.Length, sw.ElapsedMilliseconds);
return bytes;
}
catch (Exception ex)
{
sw.Stop();
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "DocToPdfBytesRaw failed after {Ms} ms", sw.ElapsedMilliseconds);
throw;
}
}
public async Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollectionAsync(Document doc)
{
var sw = Stopwatch.StartNew();
+6
View File
@@ -20,6 +20,12 @@ public interface IPdfService
/// <summary>Renders a MigraDoc Document to a PDF/A byte array.</summary>
byte[] DocToPdfBytes(Document doc);
/// <summary>
/// Renders a MigraDoc Document to a plain (non-PDF/A) PDF byte array — the visual PDF used as
/// input to eRechnung (ZUGFeRD) conversion, which owns the PDF/A-3 layer itself.
/// </summary>
byte[] DocToPdfBytesRaw(Document doc);
/// <summary>Renders a MigraDoc Document to an image collection for preview.</summary>
Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollectionAsync(Document doc);
+28 -1
View File
@@ -95,7 +95,12 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
switch (d.Target)
{
case "email": return SetNewText(s, "invoiceemail", d, ref oldValue, ref newValue);
case "address": return SetNewText(s, "invoiceaddress", d, ref oldValue, ref newValue);
case "address":
// Structured dialog form posts a JSON object; a plain string keeps the legacy
// free-text path working (backward compatibility).
return d.Value is JObject
? SetSendToAddress(s, d, ref oldValue, ref newValue)
: SetNewText(s, "invoiceaddress", d, ref oldValue, ref newValue);
case "title": return SetNewText(s, "invoicetitle", d, ref oldValue, ref newValue);
case "provisionperiod": return SetNewText(s, "provisionperiod", d, ref oldValue, ref newValue);
case "provisionlocation":
@@ -157,6 +162,26 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
private static string ContactLabel(string name, string email) =>
string.IsNullOrEmpty(name) ? email : string.IsNullOrEmpty(email) ? name : $"{name} <{email}>";
/// <summary>
/// Applies a structured recipient address: stores it as JSON inside the <c>CustomValues</c>
/// blob (interim persistence, no schema change) and composes the multi-line free-text block
/// into <c>invoiceaddress</c> so the PDF and the <c>SendToAddress</c> column are unchanged.
/// </summary>
private static bool SetSendToAddress(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
if (d.Value is not JObject vo) return false;
var addr = InvoiceRecipientAddress.FromJson(vo);
oldValue = Str(s.New["invoiceaddress"]);
JObject cvo = (JObject)TryParseObject(Str(s.New["CustomValues"])).DeepClone();
cvo[InvoiceRecipientAddress.CustomValuesKey] = addr.ToJson();
s.New["CustomValues"] = cvo.ToString(Newtonsoft.Json.Formatting.None);
newValue = addr.Compose();
s.New["invoiceaddress"] = newValue;
return true;
}
/// <summary>Replaces (or inserts) a whole block — the editor re-emits an edited block's line arrays as one delta.</summary>
private static bool ReplaceBlock(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
@@ -590,6 +615,8 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
["InvoiceBalance"] = session.Sums.TotalGross,
["InvoiceBalance_net"] = session.Sums.TotalNet,
["CustomValues"] = Str(session.New["CustomValues"]),
["SendToAddressJson"] = InvoiceRecipientAddress.FromCustomValues(Str(session.New["CustomValues"]))?
.ToJson().ToString(Newtonsoft.Json.Formatting.None) ?? "",
["InvoiceOptions"] = BuildInvoiceOptions(session),
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
};
+130
View File
@@ -0,0 +1,130 @@
using System.Text;
using Newtonsoft.Json.Linq;
namespace Fuchs.Services;
/// <summary>
/// Structured invoice-recipient (buyer) address held in the draft cache and persisted as JSON
/// (interim: inside the <c>CustomValues</c> blob under <c>sendToAddress</c>; a dedicated
/// <c>SendToAddressJson</c> column is planned — see ADR 0012 / the SQL plan). It replaces the
/// former free-text <c>invoiceaddress</c> as the source of truth, while a composed free-text
/// block (<see cref="Compose"/>) keeps the existing PDF/<c>SendToAddress</c> path unchanged.
/// </summary>
/// <remarks>
/// The field set targets EN 16931 / DATEV: <see cref="Name"/> (BT-44), <see cref="Contact"/>
/// (z.Hd.), <see cref="AddressLine2"/> (BT-51), <see cref="Street"/> (BT-50),
/// <see cref="PostalCode"/> (BT-53), <see cref="City"/> (BT-52), <see cref="CountryCode"/>
/// (BT-55, mandatory), <see cref="VatId"/> (BT-48, optional — empty for a private person, which
/// must stay effortless: B2C invoices need no VAT id and are still EN 16931-valid).
/// </remarks>
public sealed class InvoiceRecipientAddress
{
/// <summary>The JSON key under which the structured address rides inside <c>CustomValues</c>.</summary>
public const string CustomValuesKey = "sendToAddress";
public string Name { get; set; } = "";
public string Contact { get; set; } = "";
public string AddressLine2 { get; set; } = "";
public string Street { get; set; } = "";
public string PostalCode { get; set; } = "";
public string City { get; set; } = "";
/// <summary>ISO 3166-1 alpha-2 country code (BT-55). Defaults to <c>DE</c>.</summary>
public string CountryCode { get; set; } = "DE";
/// <summary>Buyer VAT identifier (BT-48). Empty for a private person (B2C).</summary>
public string VatId { get; set; } = "";
/// <summary>True when no VAT id is set — a private person / B2C recipient.</summary>
public bool IsPrivatePerson => string.IsNullOrWhiteSpace(VatId);
/// <summary>Reads a structured address from a JSON object (tolerant of missing keys).</summary>
public static InvoiceRecipientAddress FromJson(JObject? o)
{
o ??= new JObject();
return new InvoiceRecipientAddress
{
Name = S(o, "name"),
Contact = S(o, "contact"),
AddressLine2 = S(o, "line2", "addressLine2"),
Street = S(o, "street"),
PostalCode = S(o, "postalCode", "zip", "plz"),
City = S(o, "city", "ort"),
CountryCode = S(o, "countryCode", "country").ToUpperInvariant() is { Length: > 0 } cc ? cc : "DE",
VatId = S(o, "vatId", "ustid"),
};
}
/// <summary>Parses the structured address out of a <c>CustomValues</c> JSON blob, if present.</summary>
public static InvoiceRecipientAddress? FromCustomValues(string? customValuesJson)
{
if (string.IsNullOrWhiteSpace(customValuesJson)) return null;
try
{
var cv = JObject.Parse(customValuesJson);
return cv[CustomValuesKey] is JObject a ? FromJson(a) : null;
}
catch { return null; }
}
/// <summary>Serialises to a JSON object for the cache / <c>CustomValues</c> blob.</summary>
public JObject ToJson() => new()
{
["name"] = Name,
["contact"] = Contact,
["line2"] = AddressLine2,
["street"] = Street,
["postalCode"] = PostalCode,
["city"] = City,
["countryCode"] = CountryCode,
["vatId"] = VatId,
};
/// <summary>
/// Composes the multi-line free-text postal block (for the PDF and the legacy
/// <c>SendToAddress</c> column). The VAT id is intentionally excluded — it is not part of the
/// postal address, only of the structured eRechnung data (BT-48). Foreign countries append
/// the country code line; domestic (DE) omits it, matching the current letter layout.
/// </summary>
public string Compose()
{
var sb = new StringBuilder();
void Line(string? v) { if (!string.IsNullOrWhiteSpace(v)) sb.Append(sb.Length > 0 ? "\n" : "").Append(v!.Trim()); }
Line(Name);
if (!string.IsNullOrWhiteSpace(Contact)) Line($"z.Hd. {Contact.Trim()}");
Line(AddressLine2);
Line(Street);
string cityLine = $"{PostalCode} {City}".Trim();
Line(cityLine);
if (!string.IsNullOrWhiteSpace(CountryCode) && !CountryCode.Equals("DE", StringComparison.OrdinalIgnoreCase))
Line(CountryCode.ToUpperInvariant());
return sb.ToString();
}
/// <summary>
/// The EN 16931 fields still missing for a formally valid buyer (BG-7). Buyer name (BT-44)
/// and country code (BT-55) are mandatory; a postal address needs a city or post code. VAT id
/// is <b>not</b> required (a private person is valid without one). An empty result means the
/// recipient is EN 16931 / DATEV conformant — the editor surfaces this as a hint.
/// </summary>
public IReadOnlyList<string> MissingForEn16931()
{
var missing = new List<string>();
if (string.IsNullOrWhiteSpace(Name)) missing.Add("Name");
if (string.IsNullOrWhiteSpace(CountryCode)) missing.Add("Land");
if (string.IsNullOrWhiteSpace(City) && string.IsNullOrWhiteSpace(PostalCode)) missing.Add("Ort/PLZ");
return missing;
}
/// <summary>True when the recipient satisfies the EN 16931 mandatory buyer fields.</summary>
public bool IsEn16931Conformant => MissingForEn16931().Count == 0;
private static string S(JObject o, params string[] keys)
{
foreach (var k in keys)
if (o[k] is { } t && t.Type != JTokenType.Null)
return t.Type == JTokenType.String ? t.Value<string>()?.Trim() ?? "" : t.ToString().Trim();
return "";
}
}
+17 -4
View File
@@ -26,15 +26,17 @@ public class InvoiceService : IInvoiceService
private readonly IPdfService _pdf;
private readonly IBlobStorageService _blobStorage;
private readonly IEventService _events;
private readonly IERechnungService _erechnung;
private readonly ILogger<InvoiceService> _logger;
public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage,
IEventService events, ILogger<InvoiceService> logger)
IEventService events, IERechnungService erechnung, ILogger<InvoiceService> logger)
{
_intranet = intranet;
_pdf = pdf;
_blobStorage = blobStorage;
_events = events;
_erechnung = erechnung;
_logger = logger;
}
@@ -133,13 +135,13 @@ public class InvoiceService : IInvoiceService
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
if (!change)
{
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT;");
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
}
else
{
pl.Add(SQL_VarChar("@InvId", invId));
sqlParts.Add("EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT;");
sqlParts.Add("EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
}
if (invoice.RawProvisionLocation.Length > 0)
@@ -283,7 +285,18 @@ public class InvoiceService : IInvoiceService
}
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft)
=> Task.FromResult(_pdf.DocToPdfBytes(GenerateInvoicePdf(invoice, draft)));
{
var doc = GenerateInvoicePdf(invoice, draft);
// Finalized invoices are emitted as a ZUGFeRD/Factur-X hybrid when eRechnung is enabled:
// eRechnungLib embeds the CII XML into the render-only visual PDF and owns the single
// PDF/A-3 layer (ADR 0012). Drafts/previews keep the plain PDF/A. Any failure falls back.
if (!draft && _erechnung.Enabled)
{
var hybrid = _erechnung.TryBuildHybridPdf(invoice, _pdf.DocToPdfBytesRaw(doc));
if (hybrid is { Length: > 0 }) return Task.FromResult(hybrid);
}
return Task.FromResult(_pdf.DocToPdfBytes(doc));
}
public async Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData invoice, bool draft,
string userAccountId, DatabaseSecurity dbSec)