diff --git a/GerbilManager.Tests/ContractGeneratorTests.cs b/GerbilManager.Tests/ContractGeneratorTests.cs
new file mode 100644
index 0000000..0d5567b
--- /dev/null
+++ b/GerbilManager.Tests/ContractGeneratorTests.cs
@@ -0,0 +1,224 @@
+using System.IO.Compression;
+using System.Text;
+using System.Text.RegularExpressions;
+using DocumentFormat.OpenXml.Packaging;
+using DocumentFormat.OpenXml.Validation;
+using GerbilManagerWebAPI.Contracts;
+
+namespace GerbilManager.Tests;
+
+///
+/// FEAT-13: Tests des Abgabevertrag-Generators. Die erzeugte .docx wird als
+/// Zip geöffnet und auf word/document.xml geprüft — kein Word nötig.
+///
+public class ContractGeneratorTests
+{
+ private static BreederProfile Seller() => new()
+ {
+ ZuchtName = "Zucht Testhausen",
+ Name = "Frau Erika Muster",
+ Address = "Musterweg 1, 12345 Testhausen",
+ Phone = "0123 456789",
+ Email = "zucht@example.org",
+ Homepage = "https://zucht.example.org/",
+ City = "Testhausen",
+ };
+
+ private static ContractAnimal Krümel() => new(
+ Name: "Krümel",
+ Geschlecht: "Weiblich",
+ Geburtsdatum: new DateOnly(2025, 3, 9),
+ Farbschlag: "Agouti");
+
+ private static ContractData OneAnimal() => new(
+ Seller: Seller(),
+ Buyer: new ContractBuyer(
+ Name: "Herr Max Beispiel",
+ Address: "Beispielallee 7, 54321 Beispielstadt",
+ Phone: "0987 654321",
+ Email: "max@example.com"),
+ Animals: [Krümel()],
+ Price: 72m,
+ HandoverDate: new DateOnly(2026, 6, 5));
+
+ ///
+ /// Sichtbarer Text aus word/document.xml der erzeugten Datei. Tags werden
+ /// entfernt, damit Aussagen über Run-Grenzen hinweg möglich sind (Werte
+ /// und Fließtext liegen in getrennten w:t-Runs).
+ ///
+ private static string DocumentText(byte[] docx)
+ {
+ using var zip = new ZipArchive(new MemoryStream(docx), ZipArchiveMode.Read);
+ var entry = Assert.Single(zip.Entries, e => e.FullName == "word/document.xml");
+ using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
+ return Regex.Replace(reader.ReadToEnd(), "<[^>]+>", "");
+ }
+
+ [Fact]
+ public void EinTier_EnthaeltAlleVertragswerte()
+ {
+ var text = DocumentText(ContractGenerator.Generate(OneAnimal()));
+
+ // Verkäufer-Block (aus dem Zuchtprofil)
+ Assert.Contains("Zucht Testhausen", text);
+ Assert.Contains("Frau Erika Muster", text);
+ Assert.Contains("Musterweg 1, 12345 Testhausen", text);
+ Assert.Contains("0123 456789", text);
+ Assert.Contains("zucht@example.org", text);
+ Assert.Contains("https://zucht.example.org/", text);
+
+ // Käufer-Block
+ Assert.Contains("Herr Max Beispiel", text);
+ Assert.Contains("Beispielallee 7, 54321 Beispielstadt", text);
+ Assert.Contains("0987 654321", text);
+ Assert.Contains("max@example.com", text);
+
+ // Tier
+ Assert.Contains("Krümel", text);
+ Assert.Contains("Weiblich", text);
+ Assert.Contains("09.03.2025", text);
+ Assert.Contains("Agouti", text);
+
+ // Kaufpreis (de-DE) + Übergabedatum (TT.MM.JJJJ)
+ Assert.Contains("Kaufpreis von 72,00 €", text);
+ Assert.Contains("am 05.06.2026 dem Käufer", text);
+
+ // Unterschriftszeile: Ort des Züchters + Vertragsdatum (= Übergabedatum)
+ Assert.Contains("Testhausen, den 05.06.2026", text);
+ }
+
+ [Fact]
+ public void KeineUnersetztenTokens()
+ {
+ var text = DocumentText(ContractGenerator.Generate(OneAnimal()));
+ Assert.DoesNotContain("{{", text);
+ Assert.DoesNotContain("}}", text);
+ }
+
+ [Fact]
+ public void DreiTiere_KlontDieTierTabelleProTier()
+ {
+ var data = OneAnimal() with
+ {
+ Animals =
+ [
+ Krümel(),
+ new ContractAnimal("Fridolin", "Männlich", new DateOnly(2023, 5, 1), "Schwarz"),
+ new ContractAnimal("Luna", "Weiblich", new DateOnly(2023, 8, 15), "Gold"),
+ ],
+ };
+
+ var text = DocumentText(ContractGenerator.Generate(data));
+
+ Assert.Contains("Krümel", text);
+ Assert.Contains("Fridolin", text);
+ Assert.Contains("Luna", text);
+ Assert.Contains("01.05.2023", text);
+ Assert.Contains("15.08.2023", text);
+
+ // Pro Tier eine Tabelle: „Tierart:“ ist das fixe Label jeder Tier-Tabelle.
+ Assert.Equal(3, Regex.Matches(text, "Tierart:").Count);
+ Assert.DoesNotContain("{{", text);
+ }
+
+ [Fact]
+ public void PreisUndDatum_DeutscheFormate()
+ {
+ var data = OneAnimal() with { Price = 1234.5m, HandoverDate = new DateOnly(2026, 1, 3) };
+ var text = DocumentText(ContractGenerator.Generate(data));
+
+ Assert.Contains("Kaufpreis von 1.234,50 €", text);
+ Assert.Contains("am 03.01.2026 dem Käufer", text);
+ }
+
+ [Fact]
+ public void VertragsDatum_UeberschreibtUebergabedatumInDerUnterschriftszeile()
+ {
+ var data = OneAnimal() with { ContractDate = new DateOnly(2026, 6, 7) };
+ var text = DocumentText(ContractGenerator.Generate(data));
+
+ Assert.Contains("Testhausen, den 07.06.2026", text);
+ Assert.Contains("am 05.06.2026 dem Käufer", text); // Übergabedatum bleibt eigenständig
+ }
+
+ [Fact]
+ public void OptionaleFelder_LeerStattPlatzhalter()
+ {
+ var data = OneAnimal() with
+ {
+ Buyer = new ContractBuyer("Frau Lisa Test", "Testgasse 2, 11111 Teststadt"),
+ Animals = [new ContractAnimal("Momo", "Unbekannt")],
+ };
+
+ var text = DocumentText(ContractGenerator.Generate(data));
+
+ Assert.Contains("Frau Lisa Test", text);
+ Assert.Contains("Momo", text);
+ Assert.DoesNotContain("{{", text);
+ Assert.DoesNotContain("null", text, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void OhneTiere_WirftArgumentException()
+ {
+ var data = OneAnimal() with { Animals = [] };
+ var ex = Assert.Throws(() => ContractGenerator.Generate(data));
+ Assert.Contains("mindestens ein Tier", ex.Message);
+ }
+
+ [Fact]
+ public void ErzeugteDatei_IstEinGueltigesDocxPaket()
+ {
+ var docx = ContractGenerator.Generate(OneAnimal());
+
+ using var zip = new ZipArchive(new MemoryStream(docx), ZipArchiveMode.Read);
+ Assert.Contains(zip.Entries, e => e.FullName == "[Content_Types].xml");
+ Assert.Contains(zip.Entries, e => e.FullName == "word/document.xml");
+ // Tierfotos wurden beim Vorlagenbau entfernt, das Zucht-Logo bleibt.
+ Assert.Contains(zip.Entries, e => e.FullName == "word/media/image1.jpeg");
+ Assert.DoesNotContain(zip.Entries, e => e.FullName == "word/media/image2.jpeg");
+ }
+
+ [Fact]
+ public void ErzeugtesDokument_ValidiertGegenOpenXmlSchema()
+ {
+ // Office-2013-Schema: die Vorlage nutzt w:tblLook-Attribute der
+ // Word-2010-Form, die der 2007-Default des Validators nicht kennt.
+ var validator = new OpenXmlValidator(DocumentFormat.OpenXml.FileFormatVersions.Office2013);
+
+ var data = OneAnimal() with
+ {
+ Animals = [Krümel(), new ContractAnimal("Fridolin", "Männlich"), new ContractAnimal("Luna", "Weiblich")],
+ };
+ using var generatedDoc = WordprocessingDocument.Open(
+ new MemoryStream(ContractGenerator.Generate(data)), isEditable: false);
+ var errors = validator.Validate(generatedDoc)
+ .Select(e => $"{e.ErrorType}: {e.Description} [{e.Path?.XPath}]")
+ .ToList();
+
+ Assert.True(errors.Count == 0, "OpenXml-Validierungsfehler:\n" + string.Join("\n", errors));
+ }
+
+ [Fact]
+ public void VorlageSelbst_EnthaeltKeineEchtdaten()
+ {
+ // Die Vorlage wird hier über den Generator-Pfad geladen: ein Vertrag
+ // mit leeren Werten darf keinerlei Daten des Mustervertrags enthalten.
+ var data = new ContractData(
+ new BreederProfile(),
+ new ContractBuyer("", ""),
+ [new ContractAnimal("", "")],
+ 0m,
+ new DateOnly(2026, 1, 1));
+
+ var text = DocumentText(ContractGenerator.Generate(data));
+
+ // Stichproben der Original-PII (Name/Ort/Mail des Mustervertrags).
+ Assert.DoesNotContain("Nießner", text);
+ Assert.DoesNotContain("Hartengrund", text);
+ Assert.DoesNotContain("Ronneburg", text);
+ Assert.DoesNotContain("Schädtler", text);
+ Assert.DoesNotContain("gmx.de", text);
+ Assert.DoesNotContain("jimdofree", text);
+ }
+}
diff --git a/GerbilManagerWebAPI/Contracts/BreederProfile.cs b/GerbilManagerWebAPI/Contracts/BreederProfile.cs
new file mode 100644
index 0000000..dacbd59
--- /dev/null
+++ b/GerbilManagerWebAPI/Contracts/BreederProfile.cs
@@ -0,0 +1,29 @@
+namespace GerbilManagerWebAPI.Contracts;
+
+///
+/// Zuchtprofil — der parameterisierte Verkäufer-Block des Abgabevertrags.
+/// Phase A: wird aus der appsettings-Sektion "BreederProfile" gebunden
+/// (z. B. builder.Configuration.GetSection("BreederProfile").Get<BreederProfile>());
+/// eine Settings-UI/Entity folgt in Phase B. Die Vorlage selbst enthält KEINE
+/// echten Daten (nur {{Platzhalter}}) — Werte kommen ausschließlich von hier.
+///
+public sealed class BreederProfile
+{
+ /// Name der Zucht, z. B. „Zucht der Kleinen Chaoten“.
+ public string ZuchtName { get; set; } = "";
+
+ /// Vor- und Nachname inkl. Anrede, z. B. „Frau Erika Muster“.
+ public string Name { get; set; } = "";
+
+ /// Anschrift einzeilig: „Straße Nr, PLZ Ort“.
+ public string Address { get; set; } = "";
+
+ public string Phone { get; set; } = "";
+
+ public string Email { get; set; } = "";
+
+ public string Homepage { get; set; } = "";
+
+ /// Ort für die Unterschriftszeile („{Ort}, den {Datum}“).
+ public string City { get; set; } = "";
+}
diff --git a/GerbilManagerWebAPI/Contracts/ContractData.cs b/GerbilManagerWebAPI/Contracts/ContractData.cs
new file mode 100644
index 0000000..3c19e97
--- /dev/null
+++ b/GerbilManagerWebAPI/Contracts/ContractData.cs
@@ -0,0 +1,34 @@
+namespace GerbilManagerWebAPI.Contracts;
+
+/// Käufer/Abnehmer-Block des Abgabevertrags.
+/// Vor- und Nachname inkl. Anrede, z. B. „Herr Max Muster“.
+/// Anschrift einzeilig: „Straße Nr, PLZ Ort“.
+public sealed record ContractBuyer(
+ string Name,
+ string Address,
+ string? Phone = null,
+ string? Email = null);
+
+/// Ein abgegebenes Tier (eine Tabelle im Vertrag pro Tier).
+/// Deutscher Anzeigetext („Weiblich“/„Männlich“) — die
+/// Abbildung vom Gender-Enum passiert im Aufrufer (Phase B), der
+/// Generator bleibt frei von Modell-Abhängigkeiten.
+public sealed record ContractAnimal(
+ string Name,
+ string Geschlecht,
+ DateOnly? Geburtsdatum = null,
+ string? Farbschlag = null);
+
+///
+/// Alle Eingaben des Vertragsgenerators. Reines Daten-Objekt, keine EF-Typen.
+///
+/// Kaufpreis in Euro; gerendert als de-DE, z. B. „72,00 €“.
+/// Übergabedatum (Abschnitt 3), Format TT.MM.JJJJ.
+/// Datum der Unterschriftszeile; Standard = Übergabedatum.
+public sealed record ContractData(
+ BreederProfile Seller,
+ ContractBuyer Buyer,
+ IReadOnlyList Animals,
+ decimal Price,
+ DateOnly HandoverDate,
+ DateOnly? ContractDate = null);
diff --git a/GerbilManagerWebAPI/Contracts/ContractGenerator.cs b/GerbilManagerWebAPI/Contracts/ContractGenerator.cs
new file mode 100644
index 0000000..05d931e
--- /dev/null
+++ b/GerbilManagerWebAPI/Contracts/ContractGenerator.cs
@@ -0,0 +1,152 @@
+using System.Globalization;
+using DocumentFormat.OpenXml;
+using DocumentFormat.OpenXml.Packaging;
+using DocumentFormat.OpenXml.Wordprocessing;
+
+namespace GerbilManagerWebAPI.Contracts;
+
+///
+/// FEAT-13: Erzeugt Abgabeverträge (.docx) aus der eingebetteten Vorlage
+/// (Contracts/Templates/Abgabevertrag.docx, abgeleitet aus dem echten
+/// Mustervertrag; Boilerplate-Abschnitte 4–8 stehen verbatim in der Vorlage).
+///
+/// Reiner Dienst: keine EF-/HTTP-Abhängigkeiten — Eingabe ist ein
+/// , Ausgabe sind die fertigen .docx-Bytes.
+/// Die Vorlage enthält pro Wert genau EINEN {{Token}}-Run (beim Vorlagenbau
+/// zusammengeführt), darum genügt einfache Textersetzung; die Tier-Tabelle
+/// wird pro Tier geklont (variable Anzahl 1..n).
+///
+public static class ContractGenerator
+{
+ private const string TemplateResource = "GerbilManagerWebAPI.Contracts.Templates.Abgabevertrag.docx";
+
+ private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
+
+ /// Erzeugt den Vertrag mit der eingebetteten Standard-Vorlage.
+ public static byte[] Generate(ContractData data) => Generate(data, LoadEmbeddedTemplate());
+
+ /// Erzeugt den Vertrag mit einer expliziten Vorlage (Tests/Sonderfälle).
+ public static byte[] Generate(ContractData data, byte[] template)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+ if (data.Animals.Count == 0)
+ {
+ throw new ArgumentException("Ein Abgabevertrag braucht mindestens ein Tier.", nameof(data));
+ }
+
+ using var stream = new MemoryStream();
+ stream.Write(template);
+
+ using (var document = WordprocessingDocument.Open(stream, isEditable: true))
+ {
+ var body = document.MainDocumentPart?.Document.Body
+ ?? throw new InvalidOperationException("Vorlage ohne Dokumentrumpf.");
+
+ FillAnimalTables(body, data.Animals);
+ ReplaceTokens(body, GlobalTokens(data));
+ document.MainDocumentPart!.Document.Save();
+ }
+
+ return stream.ToArray();
+ }
+
+ ///
+ /// Die Vorlage enthält genau eine Tier-Tabelle (mit {{TierName}}). Für
+ /// jedes weitere Tier wird sie samt Abstands-Absatz geklont; danach wird
+ /// jede Tabelle mit den Werten „ihres“ Tieres gefüllt.
+ ///
+ private static void FillAnimalTables(Body body, IReadOnlyList animals)
+ {
+ var templateTable = body.Descendants()
+ .Single(t => t.InnerText.Contains("{{TierName}}"));
+
+ var tables = new List { templateTable };
+ // Abstands-Absatz hinter der Tabelle (Optik wie im Original-Mehrtier-Vertrag).
+ OpenXmlElement anchor = templateTable.NextSibling() is Paragraph spacer
+ ? spacer
+ : templateTable;
+
+ for (var i = 1; i < animals.Count; i++)
+ {
+ var clone = (Table)templateTable.CloneNode(deep: true);
+ anchor = anchor.InsertAfterSelf(clone);
+ anchor = anchor.InsertAfterSelf(new Paragraph());
+ tables.Add(clone);
+ }
+
+ for (var i = 0; i < animals.Count; i++)
+ {
+ ReplaceTokens(tables[i], AnimalTokens(animals[i]));
+ }
+ }
+
+ private static Dictionary GlobalTokens(ContractData data) => new()
+ {
+ ["{{ZuchtName}}"] = data.Seller.ZuchtName,
+ ["{{VerkaeuferName}}"] = data.Seller.Name,
+ ["{{VerkaeuferAdresse}}"] = data.Seller.Address,
+ ["{{VerkaeuferTelefon}}"] = data.Seller.Phone,
+ ["{{VerkaeuferEmail}}"] = data.Seller.Email,
+ ["{{VerkaeuferHomepage}}"] = data.Seller.Homepage,
+ ["{{KaeuferName}}"] = data.Buyer.Name,
+ ["{{KaeuferAdresse}}"] = data.Buyer.Address,
+ ["{{KaeuferTelefon}}"] = data.Buyer.Phone ?? "",
+ ["{{KaeuferEmail}}"] = data.Buyer.Email ?? "",
+ ["{{Kaufpreis}}"] = FormatPrice(data.Price),
+ ["{{Uebergabedatum}}"] = FormatDate(data.HandoverDate),
+ ["{{VertragsOrt}}"] = data.Seller.City,
+ ["{{VertragsDatum}}"] = FormatDate(data.ContractDate ?? data.HandoverDate),
+ };
+
+ private static Dictionary AnimalTokens(ContractAnimal animal) => new()
+ {
+ ["{{TierName}}"] = animal.Name,
+ ["{{TierGeschlecht}}"] = animal.Geschlecht,
+ ["{{TierGeburtsdatum}}"] = animal.Geburtsdatum is { } born ? FormatDate(born) : "",
+ ["{{TierFarbschlag}}"] = animal.Farbschlag ?? "",
+ };
+
+ /// z. B. 72m → „72,00 €“, 1234.5m → „1.234,50 €“.
+ private static string FormatPrice(decimal price) => price.ToString("N2", German) + " €";
+
+ /// TT.MM.JJJJ (deutsche Schreibweise, wie im Mustervertrag).
+ private static string FormatDate(DateOnly date) => date.ToString("dd.MM.yyyy", German);
+
+ ///
+ /// Ersetzt Tokens in allen Text-Runs unterhalb von .
+ /// Tokens liegen in der Vorlage garantiert in einzelnen Runs — kein
+ /// Run-übergreifendes Matching nötig.
+ ///
+ private static void ReplaceTokens(OpenXmlElement root, IReadOnlyDictionary tokens)
+ {
+ foreach (var text in root.Descendants())
+ {
+ if (!text.Text.Contains("{{"))
+ {
+ continue;
+ }
+
+ foreach (var (token, value) in tokens)
+ {
+ if (text.Text.Contains(token))
+ {
+ text.Text = text.Text.Replace(token, value);
+ }
+ }
+
+ if (text.Text.Length > 0 && (char.IsWhiteSpace(text.Text[0]) || char.IsWhiteSpace(text.Text[^1])))
+ {
+ text.Space = SpaceProcessingModeValues.Preserve;
+ }
+ }
+ }
+
+ private static byte[] LoadEmbeddedTemplate()
+ {
+ using var resource = typeof(ContractGenerator).Assembly.GetManifestResourceStream(TemplateResource)
+ ?? throw new InvalidOperationException($"Eingebettete Vorlage fehlt: {TemplateResource}");
+ using var buffer = new MemoryStream();
+ resource.CopyTo(buffer);
+ return buffer.ToArray();
+ }
+}
diff --git a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj
index da3989d..af6567e 100644
--- a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj
+++ b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj
@@ -8,6 +8,7 @@
+
@@ -24,4 +25,9 @@
+
+
+
+
+