From 50168b6454b834bbc393b4b94f3538f738aa46a0 Mon Sep 17 00:00:00 2001 From: Timm Bremekamp Date: Tue, 29 Sep 2026 11:54:23 +0200 Subject: [PATCH 1/5] fix: send a spec-conformant body to Invoice/Factory/saveInvoice The live API rejected every invoice factory save with 400 ("invoice expected array with 'id' and 'objectName'"), because the body sent "id":0 without objectName/mapAll. The invoice now carries objectName "Invoice" and mapAll, each position objectName "InvoicePos", and id is sent only once it is set. --- CHANGELOG.md | 6 + src/sevDesk.NET/Clients/InvoiceClient.cs | 2 +- .../Internal/ApiModels/ApiInvoiceModels.cs | 21 +++ src/sevDesk.NET/Internal/ModelMapper.cs | 13 ++ .../FactoryRequestBodyTests.cs | 129 ++++++++++++++++++ .../Helpers/RecordingHttpMessageHandler.cs | 27 ++++ 6 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 tests/sevDesk.NET.Tests/FactoryRequestBodyTests.cs create mode 100644 tests/sevDesk.NET.Tests/Helpers/RecordingHttpMessageHandler.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 24a6b63..bdade19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Fixed + +- Factory saves (`Invoice`/`InvoicePos`) were rejected by the live API (400) because the body sent `"id":0` without `objectName`/`mapAll`. Every `SaveInvoiceAsync` and `SaveInvoiceReferenceAsync` call was affected, with or without positions: sevDesk answered `invoice expected array with 'id' and 'objectName'`, and no invoice was created. The `invoice` member of `Invoice/Factory/saveInvoice` now carries `"objectName":"Invoice"` and `"mapAll":true`, each entry of `invoicePosSave` carries `"objectName":"InvoicePos"`, and `id` is sent only when it is set — omitted for a new invoice or position, the real id for an existing one. `CreateAsync` and `UpdateAsync` do not send `objectName` or `mapAll`. + ## [3.1.0] - 2026-08-16 ### Added diff --git a/src/sevDesk.NET/Clients/InvoiceClient.cs b/src/sevDesk.NET/Clients/InvoiceClient.cs index 1a21ca6..8dea766 100644 --- a/src/sevDesk.NET/Clients/InvoiceClient.cs +++ b/src/sevDesk.NET/Clients/InvoiceClient.cs @@ -86,7 +86,7 @@ private Task PostSaveInvoiceAsync(Invoice invoice, IEnumerab { var request = new ApiSaveInvoiceRequest { - Invoice = ModelMapper.ToApi(invoice), + Invoice = ModelMapper.ToApiFactory(invoice), InvoicePosSave = positions.Select(ModelMapper.ToApi).ToList() }; return _client.PostFactoryAsync("Invoice/Factory/saveInvoice", request, diff --git a/src/sevDesk.NET/Internal/ApiModels/ApiInvoiceModels.cs b/src/sevDesk.NET/Internal/ApiModels/ApiInvoiceModels.cs index f08ad1c..af6cc64 100644 --- a/src/sevDesk.NET/Internal/ApiModels/ApiInvoiceModels.cs +++ b/src/sevDesk.NET/Internal/ApiModels/ApiInvoiceModels.cs @@ -4,9 +4,22 @@ namespace sevDesk.NET.Internal.ApiModels; internal class ApiInvoice { + /// + /// Omitted while zero: a new object has no id yet, and sevDesk answers "id":0 by + /// looking up object 0 and rejecting the request. + /// [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public int Id { get; set; } + /// Required by Invoice/Factory/saveInvoice; set by . + [JsonPropertyName("objectName")] + public string? ObjectName { get; set; } + + /// Required by Invoice/Factory/saveInvoice; set by . + [JsonPropertyName("mapAll")] + public bool? MapAll { get; set; } + [JsonPropertyName("invoiceNumber")] public string? InvoiceNumber { get; set; } @@ -160,9 +173,17 @@ internal class ApiInvoice internal class ApiInvoicePos { + /// + /// Omitted while zero: a new object has no id yet, and sevDesk answers "id":0 by + /// looking up object 0 and rejecting the request. + /// [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public int Id { get; set; } + [JsonPropertyName("objectName")] + public string? ObjectName { get; set; } + [JsonPropertyName("invoice")] public ApiObjectReference? Invoice { get; set; } diff --git a/src/sevDesk.NET/Internal/ModelMapper.cs b/src/sevDesk.NET/Internal/ModelMapper.cs index cbb76a7..31a3b2d 100644 --- a/src/sevDesk.NET/Internal/ModelMapper.cs +++ b/src/sevDesk.NET/Internal/ModelMapper.cs @@ -206,6 +206,18 @@ internal static class ModelMapper PropertyIsEInvoice = FormatBool(model.PropertyIsEInvoice) }; + /// + /// Maps an invoice for the invoice member of Invoice/Factory/saveInvoice, which, + /// unlike the plain REST endpoints, requires objectName and mapAll. + /// + internal static ApiInvoice ToApiFactory(Invoice model) + { + var api = ToApi(model); + api.ObjectName = "Invoice"; + api.MapAll = true; + return api; + } + // --- InvoicePos --- internal static InvoicePos ToPublic(ApiInvoicePos api) => new() @@ -246,6 +258,7 @@ internal static class ModelMapper Text = model.Text, Discount = model.Discount, Optional = FormatBool(model.Optional), + ObjectName = "InvoicePos", MapAll = true }; diff --git a/tests/sevDesk.NET.Tests/FactoryRequestBodyTests.cs b/tests/sevDesk.NET.Tests/FactoryRequestBodyTests.cs new file mode 100644 index 0000000..c9346a6 --- /dev/null +++ b/tests/sevDesk.NET.Tests/FactoryRequestBodyTests.cs @@ -0,0 +1,129 @@ +using System.Net; +using System.Text.Json; +using sevDesk.NET.Models; +using sevDesk.NET.Models.Enums; +using sevDesk.NET.Tests.Helpers; +using Shouldly; +using Xunit; + +namespace sevDesk.NET.Tests; + +/// +/// The request bodies sent to the Factory save endpoints, checked against the fields the +/// sevDesk API requires. A body with "id":0 and without objectName/mapAll +/// is rejected by the live API with 400. +/// +public class FactoryRequestBodyTests +{ + private const string FactoryResponse = """{"objects":{"invoice":{"id":99}}}"""; + + private static (SevDeskClient Client, RecordingHttpMessageHandler Handler) CreateClient(params HttpResponseMessage[] responses) + { + var handler = new RecordingHttpMessageHandler(responses); + var client = new SevDeskClient(new HttpClient(handler) + { + BaseAddress = new Uri("https://my.sevdesk.de/api/v1/") + }); + return (client, handler); + } + + private static HttpResponseMessage Json(HttpStatusCode status, string body) => + new(status) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") }; + + private static Invoice NewInvoice(int id = 0) => new() + { + Id = id, + Contact = new SevDeskObjectReference { Id = 1234, ObjectName = "Contact" }, + InvoiceDate = new DateTime(2023, 4, 24), + InvoiceType = InvoiceType.RE, + Status = InvoiceStatus.Draft, + Currency = "EUR", + TaxType = "default", + TaxRate = 19, + TimeToPay = 30, + DiscountTime = 14, + Discount = 3 + }; + + private static InvoicePos[] NewPositions(int firstId = 0) => + [ + new InvoicePos { Id = firstId, Name = "Sichtprüfung", Quantity = 542, Price = 1.25m, TaxRate = 19, Unity = new SevDeskObjectReference { Id = 1, ObjectName = "Unity" } }, + new InvoicePos { Name = "Prüfung", Quantity = 10, Price = 2.1m, TaxRate = 19, Discount = 20, Unity = new SevDeskObjectReference { Id = 1, ObjectName = "Unity" } } + ]; + + /// + /// The complete body for and . Non-ASCII + /// characters are put in as the \u escapes System.Text.Json writes, via + /// because C# would resolve them in the literal. + /// + private static readonly string NewInvoiceBody = + """{"invoice":{"objectName":"Invoice","mapAll":true,"contact":{"id":1234,"objectName":"Contact"},"invoiceDate":"2023-04-24 00:00:00","status":100,"invoiceType":"RE","timeToPay":30,"discountTime":14,"discount":3,"currency":"EUR","taxType":"default","taxRate":19},"invoicePosSave":[{"objectName":"InvoicePos","quantity":542,"price":1.25,"name":"Sichtprüfung","unity":{"id":1,"objectName":"Unity"},"taxRate":19,"mapAll":true},{"objectName":"InvoicePos","quantity":10,"price":2.1,"name":"Prüfung","unity":{"id":1,"objectName":"Unity"},"taxRate":19,"discount":20,"mapAll":true}]}""" + .Replace("ü", "\\u00FC"); + + [Fact] + public async Task SaveInvoice_NewInvoice_SendsTheSpecConformantBody() + { + var (client, handler) = CreateClient(Json(HttpStatusCode.OK, FactoryResponse)); + + await client.Invoices.SaveInvoiceReferenceAsync(NewInvoice(), NewPositions()); + + handler.Requests[0].Body.ShouldBe(NewInvoiceBody); + } + + [Fact] + public async Task SaveInvoice_NewInvoice_SendsObjectNameAndMapAllButNoId() + { + var (client, handler) = CreateClient(Json(HttpStatusCode.OK, FactoryResponse)); + + await client.Invoices.SaveInvoiceReferenceAsync(NewInvoice(), NewPositions()); + + using var body = JsonDocument.Parse(handler.Requests[0].Body!); + var invoice = body.RootElement.GetProperty("invoice"); + invoice.TryGetProperty("id", out _).ShouldBeFalse(); + invoice.GetProperty("objectName").GetString().ShouldBe("Invoice"); + invoice.GetProperty("mapAll").GetBoolean().ShouldBeTrue(); + foreach (var position in body.RootElement.GetProperty("invoicePosSave").EnumerateArray()) + { + position.TryGetProperty("id", out _).ShouldBeFalse(); + position.GetProperty("objectName").GetString().ShouldBe("InvoicePos"); + position.GetProperty("mapAll").GetBoolean().ShouldBeTrue(); + } + } + + [Fact] + public async Task SaveInvoice_ExistingInvoice_SendsTheRealIds() + { + var (client, handler) = CreateClient(Json(HttpStatusCode.OK, FactoryResponse)); + + await client.Invoices.SaveInvoiceReferenceAsync(NewInvoice(id: 4711), NewPositions(firstId: 815)); + + using var body = JsonDocument.Parse(handler.Requests[0].Body!); + var invoice = body.RootElement.GetProperty("invoice"); + invoice.GetProperty("id").GetInt32().ShouldBe(4711); + invoice.GetProperty("objectName").GetString().ShouldBe("Invoice"); + var positions = body.RootElement.GetProperty("invoicePosSave"); + positions[0].GetProperty("id").GetInt32().ShouldBe(815); + positions[1].TryGetProperty("id", out _).ShouldBeFalse(); + } + + [Fact] + public async Task InvoiceCreateAndUpdate_DoNotSendTheFactoryOnlyFields() + { + var (client, handler) = CreateClient( + Json(HttpStatusCode.OK, """{"objects":{"id":1}}"""), + Json(HttpStatusCode.OK, """{"objects":{"id":1}}""")); + + await client.Invoices.CreateAsync(NewInvoice()); + await client.Invoices.UpdateAsync(1, NewInvoice(id: 1)); + + using var created = JsonDocument.Parse(handler.Requests[0].Body!); + using var updated = JsonDocument.Parse(handler.Requests[1].Body!); + created.RootElement.TryGetProperty("id", out _).ShouldBeFalse(); + updated.RootElement.GetProperty("id").GetInt32().ShouldBe(1); + foreach (var root in new[] { created.RootElement, updated.RootElement }) + { + root.TryGetProperty("objectName", out _).ShouldBeFalse(); + root.TryGetProperty("mapAll", out _).ShouldBeFalse(); + } + } +} diff --git a/tests/sevDesk.NET.Tests/Helpers/RecordingHttpMessageHandler.cs b/tests/sevDesk.NET.Tests/Helpers/RecordingHttpMessageHandler.cs new file mode 100644 index 0000000..5af7801 --- /dev/null +++ b/tests/sevDesk.NET.Tests/Helpers/RecordingHttpMessageHandler.cs @@ -0,0 +1,27 @@ +namespace sevDesk.NET.Tests.Helpers; + +/// +/// Answers requests from a queue of responses and records each request body as a string while +/// the request is still alive. Needed for factory writes, whose request message is disposed +/// before the call returns. +/// +internal class RecordingHttpMessageHandler : HttpMessageHandler +{ + private readonly Queue _responses; + + public List<(HttpMethod Method, Uri? Uri, string? Body)> Requests { get; } = []; + + public RecordingHttpMessageHandler(params HttpResponseMessage[] responses) + => _responses = new Queue(responses); + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + Requests.Add((request.Method, request.RequestUri, body)); + + return _responses.Count > 0 + ? _responses.Dequeue() + : throw new InvalidOperationException($"Unexpected request #{Requests.Count} to {request.RequestUri}."); + } +} From ffe45ad2258f34898e9891ccb0cd6b1c4cf6183e Mon Sep 17 00:00:00 2001 From: Timm Bremekamp Date: Tue, 29 Sep 2026 11:55:17 +0200 Subject: [PATCH 2/5] fix: stop sending "id":0 on the other factory saves and REST creates CreditNote, Order and Voucher factory saves had the same defect as the invoice factory: the document went out with "id":0 and without objectName/mapAll, its positions without objectName. They now follow the invoice. The plain REST creates sent "id":0 as well, which sevDesk answers for POST /Contact with 404 "No Model_Contact with the id 0 was found". The id is now omitted while it is zero on every model the package writes. --- CHANGELOG.md | 1 + src/sevDesk.NET/Clients/CreditNoteClient.cs | 2 +- src/sevDesk.NET/Clients/OrderClient.cs | 2 +- src/sevDesk.NET/Clients/VoucherClient.cs | 2 +- .../ApiModels/ApiCheckAccountModels.cs | 10 ++ .../Internal/ApiModels/ApiContactModels.cs | 5 + .../Internal/ApiModels/ApiCreditNoteModels.cs | 21 +++ .../Internal/ApiModels/ApiOrderModels.cs | 21 +++ .../Internal/ApiModels/ApiPartModels.cs | 25 ++++ .../Internal/ApiModels/ApiVoucherModels.cs | 21 +++ src/sevDesk.NET/Internal/ModelMapper.cs | 39 ++++++ .../FactoryRequestBodyTests.cs | 125 ++++++++++++++++++ 12 files changed, 271 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdade19..402ade0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - Factory saves (`Invoice`/`InvoicePos`) were rejected by the live API (400) because the body sent `"id":0` without `objectName`/`mapAll`. Every `SaveInvoiceAsync` and `SaveInvoiceReferenceAsync` call was affected, with or without positions: sevDesk answered `invoice expected array with 'id' and 'objectName'`, and no invoice was created. The `invoice` member of `Invoice/Factory/saveInvoice` now carries `"objectName":"Invoice"` and `"mapAll":true`, each entry of `invoicePosSave` carries `"objectName":"InvoicePos"`, and `id` is sent only when it is set — omitted for a new invoice or position, the real id for an existing one. `CreateAsync` and `UpdateAsync` do not send `objectName` or `mapAll`. +- The same defect on the other write paths. `CreditNote/Factory/saveCreditNote`, `Order/Factory/saveOrder` and `Voucher/Factory/saveVoucher` sent the document with `"id":0` and without `objectName`/`mapAll`, and their positions without `objectName`; they now follow the invoice. The plain REST creates sent `"id":0` as well, which sevDesk answers with 404 `No Model_Contact with the id 0 was found` for `Contacts.CreateAsync`; `id` is now omitted while it is zero on every model the package writes (`Contact`, `ContactAddress`, `CommunicationWay`, `Part`, `CheckAccountTransaction`, `CheckAccount`, `Category`, `Tag` and the document and position models), and sent as before once it is set. ## [3.1.0] - 2026-08-16 diff --git a/src/sevDesk.NET/Clients/CreditNoteClient.cs b/src/sevDesk.NET/Clients/CreditNoteClient.cs index 321cd17..ce92536 100644 --- a/src/sevDesk.NET/Clients/CreditNoteClient.cs +++ b/src/sevDesk.NET/Clients/CreditNoteClient.cs @@ -69,7 +69,7 @@ private Task PostSaveCreditNoteAsync(CreditNote creditNote, { var request = new ApiSaveCreditNoteRequest { - CreditNote = ModelMapper.ToApi(creditNote), + CreditNote = ModelMapper.ToApiFactory(creditNote), CreditNotePosSave = positions.Select(ModelMapper.ToApi).ToList() }; return _client.PostFactoryAsync("CreditNote/Factory/saveCreditNote", request, diff --git a/src/sevDesk.NET/Clients/OrderClient.cs b/src/sevDesk.NET/Clients/OrderClient.cs index ccb87cd..421f333 100644 --- a/src/sevDesk.NET/Clients/OrderClient.cs +++ b/src/sevDesk.NET/Clients/OrderClient.cs @@ -58,7 +58,7 @@ private Task PostSaveOrderAsync(Order order, IEnumerable PostSaveVoucherAsync(Voucher voucher, IEnumerab { var request = new ApiSaveVoucherRequest { - Voucher = ModelMapper.ToApi(voucher), + Voucher = ModelMapper.ToApiFactory(voucher), VoucherPosSave = positions.Select(ModelMapper.ToApi).ToList(), Filename = filename }; diff --git a/src/sevDesk.NET/Internal/ApiModels/ApiCheckAccountModels.cs b/src/sevDesk.NET/Internal/ApiModels/ApiCheckAccountModels.cs index bfab20b..8a0c450 100644 --- a/src/sevDesk.NET/Internal/ApiModels/ApiCheckAccountModels.cs +++ b/src/sevDesk.NET/Internal/ApiModels/ApiCheckAccountModels.cs @@ -4,7 +4,12 @@ namespace sevDesk.NET.Internal.ApiModels; internal class ApiCheckAccount { + /// + /// Omitted while zero: a new object has no id yet, and sevDesk answers "id":0 by + /// looking up object 0 and rejecting the request. + /// [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public int Id { get; set; } [JsonPropertyName("name")] @@ -40,7 +45,12 @@ internal class ApiCheckAccount internal class ApiCheckAccountTransaction { + /// + /// Omitted while zero: a new object has no id yet, and sevDesk answers "id":0 by + /// looking up object 0 and rejecting the request. + /// [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public int Id { get; set; } [JsonPropertyName("checkAccount")] diff --git a/src/sevDesk.NET/Internal/ApiModels/ApiContactModels.cs b/src/sevDesk.NET/Internal/ApiModels/ApiContactModels.cs index 1bb6d4b..2d66fdf 100644 --- a/src/sevDesk.NET/Internal/ApiModels/ApiContactModels.cs +++ b/src/sevDesk.NET/Internal/ApiModels/ApiContactModels.cs @@ -4,7 +4,12 @@ namespace sevDesk.NET.Internal.ApiModels; internal class ApiContact { + /// + /// Omitted while zero: a new object has no id yet, and sevDesk answers "id":0 by + /// looking up object 0 and rejecting the request. + /// [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public int Id { get; set; } [JsonPropertyName("objectName")] diff --git a/src/sevDesk.NET/Internal/ApiModels/ApiCreditNoteModels.cs b/src/sevDesk.NET/Internal/ApiModels/ApiCreditNoteModels.cs index 96c8d2d..a584490 100644 --- a/src/sevDesk.NET/Internal/ApiModels/ApiCreditNoteModels.cs +++ b/src/sevDesk.NET/Internal/ApiModels/ApiCreditNoteModels.cs @@ -4,9 +4,22 @@ namespace sevDesk.NET.Internal.ApiModels; internal class ApiCreditNote { + /// + /// Omitted while zero: a new object has no id yet, and sevDesk answers "id":0 by + /// looking up object 0 and rejecting the request. + /// [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public int Id { get; set; } + /// Required by CreditNote/Factory/saveCreditNote; set by . + [JsonPropertyName("objectName")] + public string? ObjectName { get; set; } + + /// Required by CreditNote/Factory/saveCreditNote; set by . + [JsonPropertyName("mapAll")] + public bool? MapAll { get; set; } + [JsonPropertyName("creditNoteNumber")] public string? CreditNoteNumber { get; set; } @@ -73,9 +86,17 @@ internal class ApiCreditNote internal class ApiCreditNotePos { + /// + /// Omitted while zero: a new object has no id yet, and sevDesk answers "id":0 by + /// looking up object 0 and rejecting the request. + /// [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public int Id { get; set; } + [JsonPropertyName("objectName")] + public string? ObjectName { get; set; } + [JsonPropertyName("creditNote")] public ApiObjectReference? CreditNote { get; set; } diff --git a/src/sevDesk.NET/Internal/ApiModels/ApiOrderModels.cs b/src/sevDesk.NET/Internal/ApiModels/ApiOrderModels.cs index d3dd744..039d91d 100644 --- a/src/sevDesk.NET/Internal/ApiModels/ApiOrderModels.cs +++ b/src/sevDesk.NET/Internal/ApiModels/ApiOrderModels.cs @@ -4,9 +4,22 @@ namespace sevDesk.NET.Internal.ApiModels; internal class ApiOrder { + /// + /// Omitted while zero: a new object has no id yet, and sevDesk answers "id":0 by + /// looking up object 0 and rejecting the request. + /// [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public int Id { get; set; } + /// Required by Order/Factory/saveOrder; set by . + [JsonPropertyName("objectName")] + public string? ObjectName { get; set; } + + /// Required by Order/Factory/saveOrder; set by . + [JsonPropertyName("mapAll")] + public bool? MapAll { get; set; } + [JsonPropertyName("orderNumber")] public string? OrderNumber { get; set; } @@ -85,9 +98,17 @@ internal class ApiOrder internal class ApiOrderPos { + /// + /// Omitted while zero: a new object has no id yet, and sevDesk answers "id":0 by + /// looking up object 0 and rejecting the request. + /// [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public int Id { get; set; } + [JsonPropertyName("objectName")] + public string? ObjectName { get; set; } + [JsonPropertyName("order")] public ApiObjectReference? Order { get; set; } diff --git a/src/sevDesk.NET/Internal/ApiModels/ApiPartModels.cs b/src/sevDesk.NET/Internal/ApiModels/ApiPartModels.cs index a413c92..97a403b 100644 --- a/src/sevDesk.NET/Internal/ApiModels/ApiPartModels.cs +++ b/src/sevDesk.NET/Internal/ApiModels/ApiPartModels.cs @@ -4,7 +4,12 @@ namespace sevDesk.NET.Internal.ApiModels; internal class ApiPart { + /// + /// Omitted while zero: a new object has no id yet, and sevDesk answers "id":0 by + /// looking up object 0 and rejecting the request. + /// [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public int Id { get; set; } [JsonPropertyName("name")] @@ -52,7 +57,12 @@ internal class ApiPart internal class ApiCommunicationWay { + /// + /// Omitted while zero: a new object has no id yet, and sevDesk answers "id":0 by + /// looking up object 0 and rejecting the request. + /// [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public int Id { get; set; } [JsonPropertyName("contact")] @@ -79,7 +89,12 @@ internal class ApiCommunicationWay internal class ApiContactAddress { + /// + /// Omitted while zero: a new object has no id yet, and sevDesk answers "id":0 by + /// looking up object 0 and rejecting the request. + /// [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public int Id { get; set; } [JsonPropertyName("contact")] @@ -121,7 +136,12 @@ internal class ApiContactAddress internal class ApiTag { + /// + /// Omitted while zero: a new object has no id yet, and sevDesk answers "id":0 by + /// looking up object 0 and rejecting the request. + /// [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public int Id { get; set; } [JsonPropertyName("name")] @@ -139,7 +159,12 @@ internal class ApiTag internal class ApiCategory { + /// + /// Omitted while zero: a new object has no id yet, and sevDesk answers "id":0 by + /// looking up object 0 and rejecting the request. + /// [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public int Id { get; set; } [JsonPropertyName("name")] diff --git a/src/sevDesk.NET/Internal/ApiModels/ApiVoucherModels.cs b/src/sevDesk.NET/Internal/ApiModels/ApiVoucherModels.cs index 23a39c5..3525810 100644 --- a/src/sevDesk.NET/Internal/ApiModels/ApiVoucherModels.cs +++ b/src/sevDesk.NET/Internal/ApiModels/ApiVoucherModels.cs @@ -4,9 +4,22 @@ namespace sevDesk.NET.Internal.ApiModels; internal class ApiVoucher { + /// + /// Omitted while zero: a new object has no id yet, and sevDesk answers "id":0 by + /// looking up object 0 and rejecting the request. + /// [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public int Id { get; set; } + /// Required by Voucher/Factory/saveVoucher; set by . + [JsonPropertyName("objectName")] + public string? ObjectName { get; set; } + + /// Required by Voucher/Factory/saveVoucher; set by . + [JsonPropertyName("mapAll")] + public bool? MapAll { get; set; } + [JsonPropertyName("voucherDate")] public string? VoucherDate { get; set; } @@ -64,9 +77,17 @@ internal class ApiVoucher internal class ApiVoucherPos { + /// + /// Omitted while zero: a new object has no id yet, and sevDesk answers "id":0 by + /// looking up object 0 and rejecting the request. + /// [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public int Id { get; set; } + [JsonPropertyName("objectName")] + public string? ObjectName { get; set; } + [JsonPropertyName("voucher")] public ApiObjectReference? Voucher { get; set; } diff --git a/src/sevDesk.NET/Internal/ModelMapper.cs b/src/sevDesk.NET/Internal/ModelMapper.cs index 31a3b2d..993e2b1 100644 --- a/src/sevDesk.NET/Internal/ModelMapper.cs +++ b/src/sevDesk.NET/Internal/ModelMapper.cs @@ -319,6 +319,18 @@ internal static ApiInvoice ToApiFactory(Invoice model) CustomerInternalNote = model.CustomerInternalNote }; + /// + /// Maps an order for the order member of Order/Factory/saveOrder, which, + /// unlike the plain REST endpoints, requires objectName and mapAll. + /// + internal static ApiOrder ToApiFactory(Order model) + { + var api = ToApi(model); + api.ObjectName = "Order"; + api.MapAll = true; + return api; + } + // --- OrderPos --- internal static OrderPos ToPublic(ApiOrderPos api) => new() @@ -356,6 +368,7 @@ internal static ApiInvoice ToApiFactory(Invoice model) Text = model.Text, Discount = model.Discount, Optional = FormatBool(model.Optional), + ObjectName = "OrderPos", MapAll = true }; @@ -401,6 +414,18 @@ internal static ApiInvoice ToApiFactory(Invoice model) TaxSet = ToApi(model.TaxSet) }; + /// + /// Maps a voucher for the voucher member of Voucher/Factory/saveVoucher, which, + /// unlike the plain REST endpoints, requires objectName and mapAll. + /// + internal static ApiVoucher ToApiFactory(Voucher model) + { + var api = ToApi(model); + api.ObjectName = "Voucher"; + api.MapAll = true; + return api; + } + // --- VoucherPos --- internal static VoucherPos ToPublic(ApiVoucherPos api) => new() @@ -430,6 +455,7 @@ internal static ApiInvoice ToApiFactory(Invoice model) TaxRate = model.TaxRate, IsAsset = FormatBool(model.IsAsset), Comment = model.Comment, + ObjectName = "VoucherPos", MapAll = true }; @@ -482,6 +508,18 @@ internal static ApiInvoice ToApiFactory(Invoice model) SmallSettlement = FormatBool(model.SmallSettlement) }; + /// + /// Maps a credit note for the creditNote member of CreditNote/Factory/saveCreditNote, which, + /// unlike the plain REST endpoints, requires objectName and mapAll. + /// + internal static ApiCreditNote ToApiFactory(CreditNote model) + { + var api = ToApi(model); + api.ObjectName = "CreditNote"; + api.MapAll = true; + return api; + } + // --- CreditNotePos --- internal static CreditNotePos ToPublic(ApiCreditNotePos api) => new() @@ -519,6 +557,7 @@ internal static ApiInvoice ToApiFactory(Invoice model) Text = model.Text, Discount = model.Discount, Optional = FormatBool(model.Optional), + ObjectName = "CreditNotePos", MapAll = true }; diff --git a/tests/sevDesk.NET.Tests/FactoryRequestBodyTests.cs b/tests/sevDesk.NET.Tests/FactoryRequestBodyTests.cs index c9346a6..c8cbdc0 100644 --- a/tests/sevDesk.NET.Tests/FactoryRequestBodyTests.cs +++ b/tests/sevDesk.NET.Tests/FactoryRequestBodyTests.cs @@ -127,3 +127,128 @@ public async Task InvoiceCreateAndUpdate_DoNotSendTheFactoryOnlyFields() } } } + +/// +/// The same defect on the other write paths: the CreditNote, Order and +/// Voucher factories, and the plain REST creates the API documents. +/// +public class OtherWriteRequestBodyTests +{ + private static (SevDeskClient Client, RecordingHttpMessageHandler Handler) CreateClient(string response) + { + var handler = new RecordingHttpMessageHandler( + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(response, System.Text.Encoding.UTF8, "application/json") }); + var client = new SevDeskClient(new HttpClient(handler) + { + BaseAddress = new Uri("https://my.sevdesk.de/api/v1/") + }); + return (client, handler); + } + + private static Task SaveAsync(SevDeskClient client, string objectName, int id, int positionId) => objectName switch + { + "CreditNote" => client.CreditNotes.SaveCreditNoteReferenceAsync( + new CreditNote { Id = id, CreditNoteNumber = "GS-099" }, + [new CreditNotePos { Id = positionId, Name = "Position 1", Quantity = 1, Price = 100 }]), + "Order" => client.Orders.SaveOrderReferenceAsync( + new Order { Id = id, OrderNumber = "AN-099" }, + [new OrderPos { Id = positionId, Name = "Position 1", Quantity = 1, Price = 100 }]), + "Voucher" => client.Vouchers.SaveVoucherReferenceAsync( + new Voucher { Id = id, Description = "Voucher 99" }, + [new VoucherPos { Id = positionId, Net = 100, TaxRate = 19 }]), + var other => throw new ArgumentOutOfRangeException(nameof(objectName), other, null) + }; + + private static string Member(string objectName) => char.ToLowerInvariant(objectName[0]) + objectName[1..]; + + private static string FactoryResponse(string objectName, int id) => + "{\"objects\":{\"" + Member(objectName) + "\":{\"id\":" + id + "}}}"; + + [Theory] + [InlineData("CreditNote")] + [InlineData("Order")] + [InlineData("Voucher")] + public async Task Factory_NewDocument_SendsObjectNameAndMapAllButNoId(string objectName) + { + var (client, handler) = CreateClient(FactoryResponse(objectName, 99)); + + await SaveAsync(client, objectName, id: 0, positionId: 0); + + using var body = JsonDocument.Parse(handler.Requests[0].Body!); + var document = body.RootElement.GetProperty(Member(objectName)); + document.TryGetProperty("id", out _).ShouldBeFalse(); + document.GetProperty("objectName").GetString().ShouldBe(objectName); + document.GetProperty("mapAll").GetBoolean().ShouldBeTrue(); + var position = body.RootElement.GetProperty(Member(objectName) + "PosSave")[0]; + position.TryGetProperty("id", out _).ShouldBeFalse(); + position.GetProperty("objectName").GetString().ShouldBe(objectName + "Pos"); + position.GetProperty("mapAll").GetBoolean().ShouldBeTrue(); + } + + [Theory] + [InlineData("CreditNote")] + [InlineData("Order")] + [InlineData("Voucher")] + public async Task Factory_ExistingDocument_SendsTheRealIds(string objectName) + { + var (client, handler) = CreateClient(FactoryResponse(objectName, 4711)); + + await SaveAsync(client, objectName, id: 4711, positionId: 815); + + using var body = JsonDocument.Parse(handler.Requests[0].Body!); + body.RootElement.GetProperty(Member(objectName)).GetProperty("id").GetInt32().ShouldBe(4711); + body.RootElement.GetProperty(Member(objectName) + "PosSave")[0].GetProperty("id").GetInt32().ShouldBe(815); + } + + [Fact] + public async Task ContactCreate_SendsNoIdAndTheCategory() + { + var (client, handler) = CreateClient("""{"objects":{"id":1,"objectName":"Contact"}}"""); + + await client.Contacts.CreateAsync(new Contact + { + Name = "Dummy", + Category = new SevDeskObjectReference { Id = 3, ObjectName = "Category" } + }); + + handler.Requests[0].Uri!.AbsolutePath.ShouldBe("/api/v1/Contact"); + using var body = JsonDocument.Parse(handler.Requests[0].Body!); + body.RootElement.TryGetProperty("id", out _).ShouldBeFalse(); + body.RootElement.GetProperty("name").GetString().ShouldBe("Dummy"); + body.RootElement.GetProperty("category").GetProperty("id").GetInt32().ShouldBe(3); + body.RootElement.GetProperty("category").GetProperty("objectName").GetString().ShouldBe("Category"); + } + + [Fact] + public async Task ContactUpdate_SendsTheRealId() + { + var (client, handler) = CreateClient("""{"objects":{"id":7,"objectName":"Contact"}}"""); + + await client.Contacts.UpdateAsync(7, new Contact { Id = 7, Name = "Dummy" }); + + using var body = JsonDocument.Parse(handler.Requests[0].Body!); + body.RootElement.GetProperty("id").GetInt32().ShouldBe(7); + } + + public static TheoryData RestCreates => ["Part", "CommunicationWay", "ContactAddress", "CheckAccountTransaction"]; + + [Theory] + [MemberData(nameof(RestCreates))] + public async Task RestCreate_NewObject_SendsNoId(string objectName) + { + var (client, handler) = CreateClient("""{"objects":{"id":1}}"""); + + await (objectName switch + { + "Part" => client.Parts.CreateAsync(new Part { Name = "Part" }), + "CommunicationWay" => (Task)client.CommunicationWays.CreateAsync(new CommunicationWay { Value = "mail@example.com" }), + "ContactAddress" => client.ContactAddresses.CreateAsync(new ContactAddress { City = "Münster" }), + "CheckAccountTransaction" => client.CheckAccountTransactions.CreateAsync(new CheckAccountTransaction { Amount = 1 }), + var other => throw new ArgumentOutOfRangeException(nameof(objectName), other, null) + }); + + handler.Requests[0].Uri!.AbsolutePath.ShouldBe("/api/v1/" + objectName); + using var body = JsonDocument.Parse(handler.Requests[0].Body!); + body.RootElement.TryGetProperty("id", out _).ShouldBeFalse(); + } +} From ec71631d8e21264153cad0b8d85e733c3f808240 Mon Sep 17 00:00:00 2001 From: Timm Bremekamp Date: Tue, 29 Sep 2026 11:56:20 +0200 Subject: [PATCH 3/5] feat: read and write document-level discounts on invoices (3.2.0) Adds DocumentDiscount, Invoice.SumDiscounts/SumDiscountNet/ SumDiscountGross and Invoice.Discounts (embed=discounts), IInvoiceClient.GetDiscountsAsync, and SaveInvoiceAsync/ SaveInvoiceReferenceAsync overloads that send discounts in the discountSave array of Invoice/Factory/saveInvoice. The new interface members have default implementations, so existing IInvoiceClient implementations keep compiling; they refuse to drop discounts silently. Verified against the live API. --- CHANGELOG.md | 25 +- Directory.Build.props | 2 +- README.md | 39 ++ .../content/guides/models-and-enums.md | 1 + src/sevDesk.NET/Clients/IInvoiceClient.cs | 96 ++++ src/sevDesk.NET/Clients/InvoiceClient.cs | 29 +- .../Internal/ApiModels/ApiCommonModels.cs | 62 +++ .../Internal/ApiModels/ApiInvoiceModels.cs | 16 + src/sevDesk.NET/Internal/ModelMapper.cs | 29 +- .../Internal/SevDeskJsonContext.cs | 1 + src/sevDesk.NET/Models/DocumentDiscount.cs | 58 +++ src/sevDesk.NET/Models/Invoice.cs | 30 +- .../sevDesk.NET.Tests/InvoiceDiscountTests.cs | 433 ++++++++++++++++++ 13 files changed, 811 insertions(+), 10 deletions(-) create mode 100644 src/sevDesk.NET/Models/DocumentDiscount.cs create mode 100644 tests/sevDesk.NET.Tests/InvoiceDiscountTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 402ade0..ef07b01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,36 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -## [Unreleased] +## [3.2.0] - 2026-09-29 + +### Added + +- Document-level discounts and surcharges on invoices (sevDesk object `Discounts`), e.g. "Kundenrabatt -3 %" on the sum of all positions. Until now they could be neither read nor written, so a caller had to reconstruct the percentage from the sums — not always unambiguously — and could not write a discounted invoice back at all. + - `DocumentDiscount`: `Text`, `IsPercentage`, `Value`, `IsSurcharge`, and the read-only `Id`, `Object`, `IsNet`, `Create`, `Update`. + - `IInvoiceClient.GetDiscountsAsync(id)` reads the discounts of one invoice (`GET /Invoice/{id}/getDiscounts`). For several invoices at once, pass `embed: "discounts"` to `ListAsync` or `GetAsync` and read `Invoice.Discounts`. + - `Invoice.SumDiscounts`, `Invoice.SumDiscountNet` and `Invoice.SumDiscountGross`, as calculated by sevDesk and passed through unchanged. They are **negative** for a discount: a 3 % discount on 1,365.00 reads as `-40.95`, while `DocumentDiscount.Value` of the same discount is the positive `3`. + - `SaveInvoiceAsync` and `SaveInvoiceReferenceAsync` overloads with an additional `IEnumerable? discounts` parameter. The discounts are sent in the `discountSave` array of `Invoice/Factory/saveInvoice` and added to the invoice. The two-phase contract of `SevDeskWriteSucceededException` applies unchanged. + + ```csharp + var invoice = await client.Invoices.SaveInvoiceAsync(invoice, positions, + [new DocumentDiscount { Text = "Kundenrabatt", IsPercentage = true, Value = 3 }]); + ``` ### Fixed - Factory saves (`Invoice`/`InvoicePos`) were rejected by the live API (400) because the body sent `"id":0` without `objectName`/`mapAll`. Every `SaveInvoiceAsync` and `SaveInvoiceReferenceAsync` call was affected, with or without positions: sevDesk answered `invoice expected array with 'id' and 'objectName'`, and no invoice was created. The `invoice` member of `Invoice/Factory/saveInvoice` now carries `"objectName":"Invoice"` and `"mapAll":true`, each entry of `invoicePosSave` carries `"objectName":"InvoicePos"`, and `id` is sent only when it is set — omitted for a new invoice or position, the real id for an existing one. `CreateAsync` and `UpdateAsync` do not send `objectName` or `mapAll`. - The same defect on the other write paths. `CreditNote/Factory/saveCreditNote`, `Order/Factory/saveOrder` and `Voucher/Factory/saveVoucher` sent the document with `"id":0` and without `objectName`/`mapAll`, and their positions without `objectName`; they now follow the invoice. The plain REST creates sent `"id":0` as well, which sevDesk answers with 404 `No Model_Contact with the id 0 was found` for `Contacts.CreateAsync`; `id` is now omitted while it is zero on every model the package writes (`Contact`, `ContactAddress`, `CommunicationWay`, `Part`, `CheckAccountTransaction`, `CheckAccount`, `Category`, `Tag` and the document and position models), and sent as before once it is set. +### Notes + +- Reading and writing document-level discounts is verified against the live API: a draft invoice saved through `SaveInvoiceAsync` with a 3 % "Kundenrabatt" on one position of 100.00 read back with `sumNet` `"97"`, `sumTax` `"18.43"`, `sumGross` `"115.43"`, `sumDiscounts` and `sumDiscountNet` `"-3"`, `sumDiscountGross` `"-3.57"`, and a discount with `discount` `"1"`, `percentage` `"1"`, `value` `"3"`, `isNet` `"1"` — through both `GetDiscountsAsync` and `embed=discounts`. The invoice kept the number it was given and did not draw one from the invoice number sequence. +- **Backwards compatible.** No existing signature changed. A save without discounts — through the existing overloads, or with `null` or an empty list — sends no `discountSave` member at all. The request body is otherwise not the one 3.1.0 sent: that body was rejected by the live API (see Fixed). +- The three members added to `IInvoiceClient` have default implementations, so custom implementations written against 3.1.0 — test fakes, for example — compile unchanged. Their defaults never drop a discount silently: the `SaveInvoiceAsync` and `SaveInvoiceReferenceAsync` overloads forward to the existing overloads when `discounts` is `null` or empty and throw `NotSupportedException` otherwise, without sending anything; `GetDiscountsAsync` throws `NotSupportedException`. The client created by `SevDeskClient` implements all three. Covered by a test with an implementation of the 3.1.0 members only. +- `DocumentDiscount` is not the early-payment discount (`Invoice.Discount` and `Invoice.DiscountTime`, "3 % Skonto within 14 days") and not the position discount (`InvoicePos.Discount`). Both are unchanged. +- `IsNet` is read-only: `discountSave` does not accept it. The sevDesk API documentation contradicts itself on the meaning of `isNet`; `"1"` is mapped to net, which is what the API returns for discounts on net invoices. +- Existing discounts of an invoice are not removed when it is saved again. `discountDelete` is not supported yet. +- Credit notes are not covered: the sevDesk API documents the discount parameters of `CreditNote/Factory/saveCreditNote` as deprecated and without effect. + ## [3.1.0] - 2026-08-16 ### Added diff --git a/Directory.Build.props b/Directory.Build.props index 358ceaf..506a1b0 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,7 +11,7 @@ - 3.1.0 + 3.2.0 sevDesk.NET Contributors MIT https://github.com/emuuu/sevDesk.NET diff --git a/README.md b/README.md index 7f1062a..986fc4b 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,45 @@ var invoice = await client.Invoices.SaveInvoiceAsync( }); ``` +### Invoice with document-level discount + +A discount on the whole document ("Kundenrabatt -3 %") is a separate `DocumentDiscount` and goes +into its own argument. sevDesk calculates the amounts and sums itself. + +```csharp +var invoice = await client.Invoices.SaveInvoiceAsync( + new Invoice + { + Contact = new SevDeskObjectReference { Id = 123, ObjectName = "Contact" }, + InvoiceDate = DateTime.Today, + TimeToPay = 30 + }, + positions, + new[] + { + new DocumentDiscount { Text = "Kundenrabatt", IsPercentage = true, Value = 3 } + }); + +// The sums come from sevDesk and are negative for a discount, while Value stays positive: +// invoice.SumDiscountNet == -40.95m for positions worth 1,365.00 + +// Read the discounts back, for one invoice or embedded in a list +IReadOnlyList discounts = await client.Invoices.GetDiscountsAsync(invoice.Id); +var withDiscounts = await client.Invoices.ListAsync(embed: "discounts"); +``` + +Three different things are called "discount" in sevDesk, and each has its own place: + +| Kind | Example | Property | +|------|---------|----------| +| Document-level discount or surcharge | Kundenrabatt 3 % on all positions | `DocumentDiscount`, `Invoice.Discounts`, `Invoice.SumDiscount*` | +| Position discount | 20 % on one position | `InvoicePos.Discount` | +| Early-payment discount | 3 % Skonto within 14 days | `Invoice.Discount`, `Invoice.DiscountTime` | + +`Text`, `IsPercentage`, `Value` and `IsSurcharge` are what gets written. `Id`, `Object`, `IsNet`, +`Create` and `Update` are filled when reading and ignored when saving; sevDesk decides itself +whether a discount applies to the net or the gross amount. + ### Get invoice PDF ```csharp diff --git a/docs/sevDesk.NET.Docs/wwwroot/content/guides/models-and-enums.md b/docs/sevDesk.NET.Docs/wwwroot/content/guides/models-and-enums.md index f4e38fe..41bd9b7 100644 --- a/docs/sevDesk.NET.Docs/wwwroot/content/guides/models-and-enums.md +++ b/docs/sevDesk.NET.Docs/wwwroot/content/guides/models-and-enums.md @@ -15,6 +15,7 @@ sevDesk.NET provides strongly-typed models for all API entities. All model prope |---|---| | `Invoice` | Full invoice with contact, dates, amounts, and tax details | | `InvoicePos` | Invoice line item with quantity, price, and tax | +| `DocumentDiscount` | Document-level discount or surcharge of an invoice (e.g. a customer discount of 3 %) | | `Order` | Offers, order confirmations, and delivery notes | | `OrderPos` | Order line item | | `Voucher` | Expense or revenue record | diff --git a/src/sevDesk.NET/Clients/IInvoiceClient.cs b/src/sevDesk.NET/Clients/IInvoiceClient.cs index 9631bf1..bbf7515 100644 --- a/src/sevDesk.NET/Clients/IInvoiceClient.cs +++ b/src/sevDesk.NET/Clients/IInvoiceClient.cs @@ -29,6 +29,24 @@ public interface IInvoiceClient /// The requested invoice. Task GetAsync(int id, string? embed = null, CancellationToken ct = default); + /// + /// Retrieves the document-level discounts and surcharges of an invoice. + /// + /// + /// To read them together with the invoice, or for many invoices at once, pass + /// embed: "discounts" to or and use + /// instead. + /// + /// The invoice identifier. + /// Cancellation token. + /// The discounts of the invoice; empty if it has none. + /// + /// The implementation predates document-level discounts and does not override this member. + /// The client returned by always does. + /// + Task> GetDiscountsAsync(int id, CancellationToken ct = default) => + throw new NotSupportedException(DiscountsNotSupported); + /// /// Creates a new invoice. /// @@ -76,6 +94,42 @@ public interface IInvoiceClient /// Task SaveInvoiceAsync(Invoice invoice, IEnumerable positions, CancellationToken ct = default); + /// + /// Saves an invoice together with its positions and document-level discounts in a single + /// transaction and reads the saved invoice back. + /// + /// + /// Behaves exactly like + /// , + /// including the two-phase contract of + /// . The discounts are + /// sent in the discountSave array and are added to the invoice; discounts it already + /// has are not removed. sevDesk calculates the discount amounts and the sums itself. + /// + /// The invoice to save. + /// The line item positions for the invoice. + /// + /// The document-level discounts and surcharges to add. or empty sends + /// the same request as the overload without discounts. + /// + /// Cancellation token. + /// The saved invoice. + /// + /// The invoice was created, but reading it back failed. Do not save it again. + /// + /// + /// The invoice was not created. Retrying is safe. + /// + /// + /// is not empty and the implementation predates document-level + /// discounts and does not override this member. Nothing was sent. The client returned by + /// always overrides it. + /// + Task SaveInvoiceAsync(Invoice invoice, IEnumerable positions, IEnumerable? discounts, CancellationToken ct = default) => + discounts is null || !discounts.Any() + ? SaveInvoiceAsync(invoice, positions, ct) + : throw new NotSupportedException(DiscountsNotSupported); + /// /// Saves an invoice together with its positions in a single transaction and returns only the /// reference to it, without reading the invoice back. @@ -102,6 +156,41 @@ public interface IInvoiceClient /// Task SaveInvoiceReferenceAsync(Invoice invoice, IEnumerable positions, CancellationToken ct = default); + /// + /// Saves an invoice together with its positions and document-level discounts in a single + /// transaction and returns only the reference to it, without reading the invoice back. + /// + /// + /// Behaves exactly like + /// . + /// The discounts are sent in the discountSave array and are added to the invoice; + /// discounts it already has are not removed. + /// + /// The invoice to save. + /// The line item positions for the invoice. + /// + /// The document-level discounts and surcharges to add. or empty sends + /// the same request as the overload without discounts. + /// + /// Cancellation token. + /// A reference carrying the identifier of the saved invoice. + /// + /// The invoice was created, but its identifier could not be read from the response. Do not save + /// it again; look it up instead. + /// + /// + /// The invoice was not created. Retrying is safe. + /// + /// + /// is not empty and the implementation predates document-level + /// discounts and does not override this member. Nothing was sent. The client returned by + /// always overrides it. + /// + Task SaveInvoiceReferenceAsync(Invoice invoice, IEnumerable positions, IEnumerable? discounts, CancellationToken ct = default) => + discounts is null || !discounts.Any() + ? SaveInvoiceReferenceAsync(invoice, positions, ct) + : throw new NotSupportedException(DiscountsNotSupported); + /// /// Changes the status of an invoice. /// @@ -159,4 +248,11 @@ public interface IInvoiceClient /// The date of the payment. /// Cancellation token. Task BookAmountAsync(int id, decimal amount, int checkAccountId, DateTime date, CancellationToken ct = default); + + // Default implementations of the members added in 3.2.0, so that implementations written + // against 3.1.0 keep compiling. They refuse to drop discounts silently. + private const string DiscountsNotSupported = + "This IInvoiceClient implementation does not support document-level discounts. " + + "Implement GetDiscountsAsync and the SaveInvoiceAsync/SaveInvoiceReferenceAsync overloads " + + "that take discounts, or use the client created by SevDeskClient."; } diff --git a/src/sevDesk.NET/Clients/InvoiceClient.cs b/src/sevDesk.NET/Clients/InvoiceClient.cs index 8dea766..9ab263c 100644 --- a/src/sevDesk.NET/Clients/InvoiceClient.cs +++ b/src/sevDesk.NET/Clients/InvoiceClient.cs @@ -70,24 +70,41 @@ public async Task UpdateAsync(int id, Invoice invoice, CancellationToke public Task DeleteAsync(int id, CancellationToken ct = default) => _client.DeleteAsync($"Invoice/{id}", ct); - public async Task SaveInvoiceAsync(Invoice invoice, IEnumerable positions, CancellationToken ct = default) + public async Task> GetDiscountsAsync(int id, CancellationToken ct = default) { - var write = await PostSaveInvoiceAsync(invoice, positions, ct).ConfigureAwait(false); + var (items, _) = await _client.GetListAsync($"Invoice/{id}/getDiscounts", null, + SevDeskJsonContext.Default.SevDeskApiListResponseApiDiscount, ct: ct).ConfigureAwait(false); + return items.Select(ModelMapper.ToPublic).ToList(); + } + + public Task SaveInvoiceAsync(Invoice invoice, IEnumerable positions, CancellationToken ct = default) => + SaveInvoiceAsync(invoice, positions, null, ct); + + public async Task SaveInvoiceAsync(Invoice invoice, IEnumerable positions, IEnumerable? discounts, CancellationToken ct = default) + { + var write = await PostSaveInvoiceAsync(invoice, positions, discounts, ct).ConfigureAwait(false); return await BaseClient.ReadBackAfterWriteAsync(write, "Invoice", () => GetAsync(write.Id, ct: ct)).ConfigureAwait(false); } - public async Task SaveInvoiceReferenceAsync(Invoice invoice, IEnumerable positions, CancellationToken ct = default) + public Task SaveInvoiceReferenceAsync(Invoice invoice, IEnumerable positions, CancellationToken ct = default) => + SaveInvoiceReferenceAsync(invoice, positions, null, ct); + + public async Task SaveInvoiceReferenceAsync(Invoice invoice, IEnumerable positions, IEnumerable? discounts, CancellationToken ct = default) { - var write = await PostSaveInvoiceAsync(invoice, positions, ct).ConfigureAwait(false); + var write = await PostSaveInvoiceAsync(invoice, positions, discounts, ct).ConfigureAwait(false); return new SevDeskObjectReference { Id = write.Id, ObjectName = "Invoice" }; } - private Task PostSaveInvoiceAsync(Invoice invoice, IEnumerable positions, CancellationToken ct) + private Task PostSaveInvoiceAsync(Invoice invoice, IEnumerable positions, IEnumerable? discounts, CancellationToken ct) { + var discountSave = discounts?.Select(ModelMapper.ToApiSave).ToList(); var request = new ApiSaveInvoiceRequest { Invoice = ModelMapper.ToApiFactory(invoice), - InvoicePosSave = positions.Select(ModelMapper.ToApi).ToList() + InvoicePosSave = positions.Select(ModelMapper.ToApi).ToList(), + // Omitted entirely when there is nothing to add, so a save without discounts sends + // no discountSave member at all. + DiscountSave = discountSave is { Count: > 0 } ? discountSave : null }; return _client.PostFactoryAsync("Invoice/Factory/saveInvoice", request, SevDeskJsonContext.Default.ApiSaveInvoiceRequest, "invoice", "Invoice", ct); diff --git a/src/sevDesk.NET/Internal/ApiModels/ApiCommonModels.cs b/src/sevDesk.NET/Internal/ApiModels/ApiCommonModels.cs index a0c75ad..fd25da5 100644 --- a/src/sevDesk.NET/Internal/ApiModels/ApiCommonModels.cs +++ b/src/sevDesk.NET/Internal/ApiModels/ApiCommonModels.cs @@ -18,6 +18,68 @@ internal class ApiSaveInvoiceRequest [JsonPropertyName("invoicePosSave")] public List? InvoicePosSave { get; set; } + + [JsonPropertyName("discountSave")] + public List? DiscountSave { get; set; } +} + +/// +/// A document-level discount as returned by GET /Invoice/{id}/getDiscounts and +/// embed=discounts. The flags arrive as "0"/"1" strings. +/// +internal class ApiDiscount +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("object")] + public ApiObjectReference? Object { get; set; } + + [JsonPropertyName("discount")] + public string? Discount { get; set; } + + [JsonPropertyName("text")] + public string? Text { get; set; } + + [JsonPropertyName("percentage")] + public string? Percentage { get; set; } + + [JsonPropertyName("value")] + public decimal? Value { get; set; } + + [JsonPropertyName("isNet")] + public string? IsNet { get; set; } + + [JsonPropertyName("create")] + public string? Create { get; set; } + + [JsonPropertyName("update")] + public string? Update { get; set; } +} + +/// +/// An entry of the discountSave array of a factory save. Unlike the read model, the +/// flags are JSON booleans here, and isNet is not accepted. +/// +internal class ApiDiscountSave +{ + [JsonPropertyName("discount")] + public bool Discount { get; set; } + + [JsonPropertyName("text")] + public string? Text { get; set; } + + [JsonPropertyName("percentage")] + public bool Percentage { get; set; } + + [JsonPropertyName("value")] + public decimal Value { get; set; } + + [JsonPropertyName("objectName")] + public string ObjectName { get; set; } = "Discounts"; + + [JsonPropertyName("mapAll")] + public bool MapAll { get; set; } = true; } internal class ApiSaveOrderRequest diff --git a/src/sevDesk.NET/Internal/ApiModels/ApiInvoiceModels.cs b/src/sevDesk.NET/Internal/ApiModels/ApiInvoiceModels.cs index af6cc64..33c1981 100644 --- a/src/sevDesk.NET/Internal/ApiModels/ApiInvoiceModels.cs +++ b/src/sevDesk.NET/Internal/ApiModels/ApiInvoiceModels.cs @@ -109,6 +109,15 @@ internal class ApiInvoice [JsonPropertyName("sumTax")] public decimal? SumTax { get; set; } + [JsonPropertyName("sumDiscounts")] + public decimal? SumDiscounts { get; set; } + + [JsonPropertyName("sumDiscountNet")] + public decimal? SumDiscountNet { get; set; } + + [JsonPropertyName("sumDiscountGross")] + public decimal? SumDiscountGross { get; set; } + [JsonPropertyName("taxType")] public string? TaxType { get; set; } @@ -169,6 +178,13 @@ internal class ApiInvoice /// [JsonPropertyName("positions")] public List? Positions { get; set; } + + /// + /// Discounts embedded via embed=discounts. Read-only — never serialized back + /// to the API, which expects discounts in the dedicated discountSave array. + /// + [JsonPropertyName("discounts")] + public List? Discounts { get; set; } } internal class ApiInvoicePos diff --git a/src/sevDesk.NET/Internal/ModelMapper.cs b/src/sevDesk.NET/Internal/ModelMapper.cs index 993e2b1..969ade8 100644 --- a/src/sevDesk.NET/Internal/ModelMapper.cs +++ b/src/sevDesk.NET/Internal/ModelMapper.cs @@ -141,6 +141,9 @@ internal static class ModelMapper SumNet = api.SumNet, SumGross = api.SumGross, SumTax = api.SumTax, + SumDiscounts = api.SumDiscounts, + SumDiscountNet = api.SumDiscountNet, + SumDiscountGross = api.SumDiscountGross, TaxType = api.TaxType, TaxRate = api.TaxRate, TaxText = api.TaxText, @@ -159,7 +162,8 @@ internal static class ModelMapper PropertyIsEInvoice = ParseBool(api.PropertyIsEInvoice), PaidAmount = api.PaidAmount, PayDate = ParseDateTime(api.PayDate), - Positions = api.Positions?.Select(ToPublic).ToList() + Positions = api.Positions?.Select(ToPublic).ToList(), + Discounts = api.Discounts?.Select(ToPublic).ToList() }; internal static ApiInvoice ToApi(Invoice model) => new() @@ -262,6 +266,29 @@ internal static ApiInvoice ToApiFactory(Invoice model) MapAll = true }; + // --- DocumentDiscount --- + + internal static DocumentDiscount ToPublic(ApiDiscount api) => new() + { + Id = api.Id, + Object = ToPublic(api.Object), + Text = api.Text, + IsPercentage = ParseBool(api.Percentage) ?? false, + Value = api.Value ?? 0m, + IsSurcharge = ParseBool(api.Discount) == false, + IsNet = ParseBool(api.IsNet), + Create = ParseDateTime(api.Create), + Update = ParseDateTime(api.Update) + }; + + internal static ApiDiscountSave ToApiSave(DocumentDiscount model) => new() + { + Discount = !model.IsSurcharge, + Text = model.Text, + Percentage = model.IsPercentage, + Value = model.Value + }; + // --- Order --- internal static Order ToPublic(ApiOrder api) => new() diff --git a/src/sevDesk.NET/Internal/SevDeskJsonContext.cs b/src/sevDesk.NET/Internal/SevDeskJsonContext.cs index ccd9245..a654fea 100644 --- a/src/sevDesk.NET/Internal/SevDeskJsonContext.cs +++ b/src/sevDesk.NET/Internal/SevDeskJsonContext.cs @@ -47,6 +47,7 @@ namespace sevDesk.NET.Internal; [JsonSerializable(typeof(SevDeskApiListResponse))] [JsonSerializable(typeof(SevDeskApiResponse))] [JsonSerializable(typeof(SevDeskApiListResponse))] +[JsonSerializable(typeof(SevDeskApiListResponse))] [JsonSerializable(typeof(ApiSaveInvoiceRequest))] [JsonSerializable(typeof(ApiSaveOrderRequest))] [JsonSerializable(typeof(ApiSaveVoucherRequest))] diff --git a/src/sevDesk.NET/Models/DocumentDiscount.cs b/src/sevDesk.NET/Models/DocumentDiscount.cs new file mode 100644 index 0000000..ba70bfc --- /dev/null +++ b/src/sevDesk.NET/Models/DocumentDiscount.cs @@ -0,0 +1,58 @@ +namespace sevDesk.NET.Models; + +/// +/// Represents a document-level discount or surcharge of a sevDesk document (sevDesk object +/// Discounts), e.g. a customer discount of 3 % on the sum of all positions. +/// +/// +/// Not to be confused with the early-payment discount of an invoice +/// ( and ) or with the discount +/// of a single position (). A document-level discount refers to +/// the document as a whole and cannot be restricted to individual positions. +/// +public class DocumentDiscount +{ + /// Gets the discount ID. 0 for a discount that has not been saved yet. + public int Id { get; init; } + + /// Gets the document the discount belongs to. Read-only. + public SevDeskObjectReference? Object { get; init; } + + /// Gets or sets the text shown on the document, e.g. "Kundenrabatt". + public string? Text { get; init; } + + /// + /// Gets or sets whether is a percentage () or an + /// absolute amount in the document currency (). + /// + public bool IsPercentage { get; init; } + + /// + /// Gets or sets the value of the discount, as a positive number: the percentage if + /// is set, the amount otherwise. + /// + public decimal Value { get; init; } + + /// + /// Gets or sets whether this is a surcharge rather than a discount. Defaults to + /// , a discount. + /// + public bool IsSurcharge { get; init; } + + /// + /// Gets whether the discount applies to the net amount. Read-only — sevDesk derives it from + /// the document and does not accept it on save. + /// + /// + /// Mapped from the isNet field as "1" = net. The sevDesk API documentation + /// contradicts itself on this field; the mapping follows the values the API returns for + /// discounts on net invoices. + /// + public bool? IsNet { get; init; } + + /// Gets the creation timestamp. + public DateTime? Create { get; init; } + + /// Gets the last update timestamp. + public DateTime? Update { get; init; } +} diff --git a/src/sevDesk.NET/Models/Invoice.cs b/src/sevDesk.NET/Models/Invoice.cs index a6fe34b..5ca17ad 100644 --- a/src/sevDesk.NET/Models/Invoice.cs +++ b/src/sevDesk.NET/Models/Invoice.cs @@ -104,6 +104,24 @@ public class Invoice /// Gets or sets the total tax amount. public decimal? SumTax { get; init; } + /// + /// Gets the sum of all document-level discounts and surcharges (see ) + /// as reported by sevDesk; negative for a discount. Calculated by the API — read-only. + /// + public decimal? SumDiscounts { get; init; } + + /// + /// Gets the net sum of all document-level discounts and surcharges; negative for a discount. + /// Calculated by the API — read-only. + /// + public decimal? SumDiscountNet { get; init; } + + /// + /// Gets the gross sum of all document-level discounts and surcharges; negative for a discount. + /// Calculated by the API — read-only. + /// + public decimal? SumDiscountGross { get; init; } + /// Gets or sets the tax type (default, eu, noteu, custom). public string? TaxType { get; init; } @@ -161,7 +179,17 @@ public class Invoice /// /// Gets the invoice positions when they were requested via embed=positions; /// otherwise. Read-only — use - /// to write positions. + /// + /// to write positions. /// public IReadOnlyList? Positions { get; init; } + + /// + /// Gets the document-level discounts and surcharges when they were requested via + /// embed=discounts; otherwise. Read-only — use + /// + /// to write discounts, and to read them + /// for a single invoice. + /// + public IReadOnlyList? Discounts { get; init; } } diff --git a/tests/sevDesk.NET.Tests/InvoiceDiscountTests.cs b/tests/sevDesk.NET.Tests/InvoiceDiscountTests.cs new file mode 100644 index 0000000..810e6c4 --- /dev/null +++ b/tests/sevDesk.NET.Tests/InvoiceDiscountTests.cs @@ -0,0 +1,433 @@ +using System.Net; +using System.Text.Json; +using sevDesk.NET.Clients; +using sevDesk.NET.Exceptions; +using sevDesk.NET.Models; +using sevDesk.NET.Models.Enums; +using sevDesk.NET.Tests.Helpers; +using Shouldly; +using Xunit; + +namespace sevDesk.NET.Tests; + +/// +/// Document-level discounts (sevDesk object Discounts), as opposed to the early-payment +/// discount of an invoice () and the discount of a position +/// (). The response shapes are those recorded from +/// GET /Invoice/{id}?embed=discounts and GET /Invoice/{id}/getDiscounts. +/// +public class InvoiceDiscountTests +{ + private const string FactoryResponse = """{"objects":{"invoice":{"id":99}}}"""; + + private const string KundenrabattJson = """ + {"id":"2263896","objectName":"Discounts","additionalInformation":null, + "create":"2023-04-24T07:59:09+02:00","update":"2023-04-24T07:59:09+02:00", + "object":{"id":"52359836","objectName":"Invoice"},"sevClient":"1", + "discount":"1","text":"Kundenrabatt","percentage":"1","value":"3","isNet":"1"} + """; + + private static (SevDeskClient Client, RecordingHttpMessageHandler Handler) CreateClient(params HttpResponseMessage[] responses) + { + var handler = new RecordingHttpMessageHandler(responses); + var client = new SevDeskClient(new HttpClient(handler) + { + BaseAddress = new Uri("https://my.sevdesk.de/api/v1/") + }); + return (client, handler); + } + + private static HttpResponseMessage Json(HttpStatusCode status, string body) => + new(status) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") }; + + /// + /// An invoice with an early-payment discount on the invoice, a discount on one + /// position, and nothing else discount-related. + /// + private static Invoice SampleInvoice() => new() + { + Contact = new SevDeskObjectReference { Id = 1234, ObjectName = "Contact" }, + InvoiceDate = new DateTime(2023, 4, 24), + InvoiceType = InvoiceType.RE, + Status = InvoiceStatus.Draft, + Currency = "EUR", + TaxType = "default", + TaxRate = 19, + TimeToPay = 30, + DiscountTime = 14, + Discount = 3 + }; + + private static InvoicePos[] SamplePositions() => + [ + new InvoicePos { Name = "Sichtprüfung", Quantity = 542, Price = 1.25m, TaxRate = 19, Unity = new SevDeskObjectReference { Id = 1, ObjectName = "Unity" } }, + new InvoicePos { Name = "Prüfung", Quantity = 10, Price = 2.1m, TaxRate = 19, Discount = 20, Unity = new SevDeskObjectReference { Id = 1, ObjectName = "Unity" } } + ]; + + private static DocumentDiscount Kundenrabatt() => new() { Text = "Kundenrabatt", IsPercentage = true, Value = 3 }; + + /// + /// The request body without discounts for and + /// , as pins it down. + /// Non-ASCII characters are put in as the \u escapes System.Text.Json writes, via + /// because C# would resolve them in the literal. + /// + private static readonly string RequestBodyWithoutDiscounts = + """{"invoice":{"objectName":"Invoice","mapAll":true,"contact":{"id":1234,"objectName":"Contact"},"invoiceDate":"2023-04-24 00:00:00","status":100,"invoiceType":"RE","timeToPay":30,"discountTime":14,"discount":3,"currency":"EUR","taxType":"default","taxRate":19},"invoicePosSave":[{"objectName":"InvoicePos","quantity":542,"price":1.25,"name":"Sichtprüfung","unity":{"id":1,"objectName":"Unity"},"taxRate":19,"mapAll":true},{"objectName":"InvoicePos","quantity":10,"price":2.1,"name":"Prüfung","unity":{"id":1,"objectName":"Unity"},"taxRate":19,"discount":20,"mapAll":true}]}""" + .Replace("ü", "\\u00FC"); + + // --------------------------------------------------------------------- + // Reading + // --------------------------------------------------------------------- + + [Fact] + public async Task GetAsync_WithEmbeddedDiscounts_ReadsDiscountSumsAndDiscounts() + { + // E20231619: positions 1,365.00, customer discount -3 % (40.95), net 1,324.05. + var (client, handler) = CreateClient(Json(HttpStatusCode.OK, $$""" + {"objects":[{"id":"52359836","objectName":"Invoice","invoiceNumber":"E20231619", + "discountTime":"14","discount":"3","currency":"EUR", + "sumNet":"1324.05","sumTax":"251.57","sumGross":"1575.62", + "sumDiscounts":"-40.95","sumDiscountsForeignCurrency":"0", + "sumDiscountNet":"-40.95","sumDiscountGross":"-48.74", + "sumDiscountNetForeignCurrency":"0","sumDiscountGrossForeignCurrency":"0", + "showNet":"1","discounts":[{{KundenrabattJson}}]}]} + """)); + + var invoice = await client.Invoices.GetAsync(52359836, embed: "discounts"); + + handler.Requests[0].Uri!.PathAndQuery.ShouldBe("/api/v1/Invoice/52359836?embed=discounts"); + invoice.SumNet.ShouldBe(1324.05m); + invoice.SumDiscounts.ShouldBe(-40.95m); + invoice.SumDiscountNet.ShouldBe(-40.95m); + invoice.SumDiscountGross.ShouldBe(-48.74m); + + var discount = invoice.Discounts.ShouldHaveSingleItem(); + discount.Id.ShouldBe(2263896); + discount.Object.ShouldNotBeNull(); + discount.Object.Id.ShouldBe(52359836); + discount.Object.ObjectName.ShouldBe("Invoice"); + discount.Text.ShouldBe("Kundenrabatt"); + discount.IsPercentage.ShouldBeTrue(); + discount.Value.ShouldBe(3m); + discount.IsSurcharge.ShouldBeFalse(); + discount.IsNet.ShouldBe(true); + discount.Create.ShouldNotBeNull(); + } + + [Fact] + public async Task GetAsync_WithoutEmbed_LeavesDiscountsNull() + { + var (client, _) = CreateClient(Json(HttpStatusCode.OK, + """{"objects":[{"id":"1","sumDiscounts":"0","sumDiscountNet":"0","sumDiscountGross":"0"}]}""")); + + var invoice = await client.Invoices.GetAsync(1); + + invoice.Discounts.ShouldBeNull(); + invoice.SumDiscounts.ShouldBe(0m); + } + + [Fact] + public async Task GetDiscountsAsync_ReadsTheDiscountsOfTheInvoice() + { + var (client, handler) = CreateClient(Json(HttpStatusCode.OK, + $$"""{"objects":[{{KundenrabattJson}}],"total":null}""")); + + var discounts = await client.Invoices.GetDiscountsAsync(52359836); + + handler.Requests[0].Method.ShouldBe(HttpMethod.Get); + handler.Requests[0].Uri!.AbsolutePath.ShouldBe("/api/v1/Invoice/52359836/getDiscounts"); + var discount = discounts.ShouldHaveSingleItem(); + discount.Text.ShouldBe("Kundenrabatt"); + discount.IsPercentage.ShouldBeTrue(); + discount.Value.ShouldBe(3m); + } + + [Fact] + public async Task GetDiscountsAsync_InvoiceWithoutDiscounts_ReturnsEmpty() + { + var (client, _) = CreateClient(Json(HttpStatusCode.OK, """{"objects":[],"total":null}""")); + + var discounts = await client.Invoices.GetDiscountsAsync(61404832); + + discounts.ShouldBeEmpty(); + } + + [Fact] + public async Task GetDiscountsAsync_Surcharge_IsReadAsSurcharge() + { + var (client, _) = CreateClient(Json(HttpStatusCode.OK, """ + {"objects":[{"id":"7","objectName":"Discounts","discount":"0","text":"Mindermengenzuschlag","percentage":"0","value":"25.5","isNet":"1"}]} + """)); + + var discount = (await client.Invoices.GetDiscountsAsync(1)).ShouldHaveSingleItem(); + + discount.IsSurcharge.ShouldBeTrue(); + discount.IsPercentage.ShouldBeFalse(); + discount.Value.ShouldBe(25.5m); + } + + // --------------------------------------------------------------------- + // Writing + // --------------------------------------------------------------------- + + [Fact] + public async Task SaveInvoiceReferenceAsync_WithPercentageDiscount_SendsDiscountSave() + { + var (client, handler) = CreateClient(Json(HttpStatusCode.OK, FactoryResponse)); + + var reference = await client.Invoices.SaveInvoiceReferenceAsync(SampleInvoice(), SamplePositions(), [Kundenrabatt()]); + + reference.Id.ShouldBe(99); + handler.Requests.Count.ShouldBe(1); + handler.Requests[0].Uri!.AbsolutePath.ShouldBe("/api/v1/Invoice/Factory/saveInvoice"); + + using var body = JsonDocument.Parse(handler.Requests[0].Body!); + var entry = body.RootElement.GetProperty("discountSave").EnumerateArray().ShouldHaveSingleItem(); + entry.GetProperty("discount").GetBoolean().ShouldBeTrue(); + entry.GetProperty("text").GetString().ShouldBe("Kundenrabatt"); + entry.GetProperty("percentage").GetBoolean().ShouldBeTrue(); + entry.GetProperty("value").GetDecimal().ShouldBe(3m); + entry.GetProperty("objectName").GetString().ShouldBe("Discounts"); + entry.GetProperty("mapAll").GetBoolean().ShouldBeTrue(); + entry.TryGetProperty("isNet", out _).ShouldBeFalse(); + entry.TryGetProperty("id", out _).ShouldBeFalse(); + } + + [Fact] + public async Task SaveInvoiceReferenceAsync_WithAbsoluteSurcharge_SendsDiscountFalseAndPercentageFalse() + { + var (client, handler) = CreateClient(Json(HttpStatusCode.OK, FactoryResponse)); + + await client.Invoices.SaveInvoiceReferenceAsync(SampleInvoice(), SamplePositions(), + [new DocumentDiscount { Text = "Mindermengenzuschlag", IsSurcharge = true, IsPercentage = false, Value = 25.5m }]); + + using var body = JsonDocument.Parse(handler.Requests[0].Body!); + var entry = body.RootElement.GetProperty("discountSave")[0]; + entry.GetProperty("discount").GetBoolean().ShouldBeFalse(); + entry.GetProperty("percentage").GetBoolean().ShouldBeFalse(); + entry.GetProperty("value").GetDecimal().ShouldBe(25.5m); + } + + [Fact] + public async Task SaveInvoiceAsync_WithDiscount_SendsDiscountSaveAndReadsTheInvoiceBack() + { + var (client, handler) = CreateClient( + Json(HttpStatusCode.OK, FactoryResponse), + Json(HttpStatusCode.OK, """{"objects":[{"id":"99","sumDiscounts":"-40.95","sumDiscountNet":"-40.95","sumDiscountGross":"-48.74"}]}""")); + + var invoice = await client.Invoices.SaveInvoiceAsync(SampleInvoice(), SamplePositions(), [Kundenrabatt()]); + + handler.Requests.Count.ShouldBe(2); + handler.Requests[0].Body!.ShouldContain("\"discountSave\":["); + handler.Requests[1].Method.ShouldBe(HttpMethod.Get); + invoice.Id.ShouldBe(99); + invoice.SumDiscountNet.ShouldBe(-40.95m); + } + + [Fact] + public async Task SaveInvoiceAsync_WithDiscount_WhenReadBackFails_ReportsTheWrite() + { + var (client, handler) = CreateClient( + Json(HttpStatusCode.OK, FactoryResponse), + Json(HttpStatusCode.InternalServerError, """{"error":{"message":"boom"}}""")); + + var ex = await Should.ThrowAsync( + () => client.Invoices.SaveInvoiceAsync(SampleInvoice(), SamplePositions(), [Kundenrabatt()])); + + ex.ObjectName.ShouldBe("Invoice"); + ex.ObjectId.ShouldBe(99); + handler.Requests.Count.ShouldBe(2); + } + + [Fact] + public async Task SaveInvoiceAsync_WithDiscount_WhenPostFails_DoesNotClaimAWrite() + { + var (client, handler) = CreateClient( + Json(HttpStatusCode.BadRequest, """{"error":{"message":"discountSave invalid"}}""")); + + var ex = await Should.ThrowAsync( + () => client.Invoices.SaveInvoiceAsync(SampleInvoice(), SamplePositions(), [Kundenrabatt()])); + + ex.ShouldNotBeOfType(); + handler.Requests.Count.ShouldBe(1); + } + + // --------------------------------------------------------------------- + // A save without discounts sends exactly the body without discountSave + // --------------------------------------------------------------------- + + [Fact] + public async Task SaveInvoiceReferenceAsync_WithoutDiscounts_SendsNoDiscountSave() + { + var (client, handler) = CreateClient(Json(HttpStatusCode.OK, FactoryResponse)); + + await client.Invoices.SaveInvoiceReferenceAsync(SampleInvoice(), SamplePositions()); + + handler.Requests[0].Body.ShouldBe(RequestBodyWithoutDiscounts); + } + + [Fact] + public async Task SaveInvoiceAsync_WithoutDiscounts_SendsNoDiscountSave() + { + var (client, handler) = CreateClient( + Json(HttpStatusCode.OK, FactoryResponse), + Json(HttpStatusCode.OK, """{"objects":[{"id":"99"}]}""")); + + await client.Invoices.SaveInvoiceAsync(SampleInvoice(), SamplePositions()); + + handler.Requests[0].Body.ShouldBe(RequestBodyWithoutDiscounts); + } + + [Fact] + public async Task SaveInvoiceReferenceAsync_WithNullOrEmptyDiscounts_SendsNoDiscountSave() + { + var (client, handler) = CreateClient( + Json(HttpStatusCode.OK, FactoryResponse), + Json(HttpStatusCode.OK, FactoryResponse)); + + await client.Invoices.SaveInvoiceReferenceAsync(SampleInvoice(), SamplePositions(), null); + await client.Invoices.SaveInvoiceReferenceAsync(SampleInvoice(), SamplePositions(), []); + + handler.Requests[0].Body.ShouldBe(RequestBodyWithoutDiscounts); + handler.Requests[1].Body.ShouldBe(RequestBodyWithoutDiscounts); + } + + // --------------------------------------------------------------------- + // The three kinds of discount stay apart + // --------------------------------------------------------------------- + + [Fact] + public async Task Save_EarlyPaymentPositionAndDocumentDiscount_AreSentToTheirOwnFields() + { + var (client, handler) = CreateClient(Json(HttpStatusCode.OK, FactoryResponse)); + + await client.Invoices.SaveInvoiceReferenceAsync(SampleInvoice(), SamplePositions(), [Kundenrabatt()]); + + using var body = JsonDocument.Parse(handler.Requests[0].Body!); + var root = body.RootElement; + + // Early-payment discount ("14 Tage 3 % Skonto"): invoice.discount + invoice.discountTime. + root.GetProperty("invoice").GetProperty("discount").GetDecimal().ShouldBe(3m); + root.GetProperty("invoice").GetProperty("discountTime").GetInt32().ShouldBe(14); + + // Position discount: invoicePosSave[].discount, only on the position that has one. + root.GetProperty("invoicePosSave")[0].TryGetProperty("discount", out _).ShouldBeFalse(); + root.GetProperty("invoicePosSave")[1].GetProperty("discount").GetDecimal().ShouldBe(20m); + + // Document-level discount: its own array, not folded into the invoice or a position. + root.GetProperty("discountSave")[0].GetProperty("text").GetString().ShouldBe("Kundenrabatt"); + root.GetProperty("invoice").TryGetProperty("discounts", out _).ShouldBeFalse(); + root.GetProperty("invoice").TryGetProperty("sumDiscounts", out _).ShouldBeFalse(); + + // Everything except discountSave is the request without discounts. + var withoutDiscountSave = handler.Requests[0].Body!.Replace( + root.GetProperty("discountSave").GetRawText(), "").Replace(",\"discountSave\":", ""); + withoutDiscountSave.ShouldBe(RequestBodyWithoutDiscounts); + } + + [Fact] + public async Task Read_EarlyPaymentPositionAndDocumentDiscount_AreReadIntoTheirOwnProperties() + { + var (client, _) = CreateClient(Json(HttpStatusCode.OK, $$""" + {"objects":[{"id":"52359836","discountTime":"14","discount":"3", + "sumDiscounts":"-40.95","sumDiscountNet":"-40.95","sumDiscountGross":"-48.74", + "positions":[{"id":"1","name":"Prüfung","quantity":"10","price":"2.1","discount":"20","taxRate":"19"}], + "discounts":[{{KundenrabattJson}}]}]} + """)); + + var invoice = await client.Invoices.GetAsync(52359836, embed: "positions,discounts"); + + invoice.Discount.ShouldBe(3m); + invoice.DiscountTime.ShouldBe(14); + invoice.Positions.ShouldHaveSingleItem().Discount.ShouldBe(20m); + invoice.Discounts.ShouldHaveSingleItem().Value.ShouldBe(3m); + invoice.SumDiscountNet.ShouldBe(-40.95m); + } + + // --------------------------------------------------------------------- + // Implementations written against 3.1.0 keep compiling + // --------------------------------------------------------------------- + + /// + /// Implements exactly the members had in 3.1.0 and nothing else, + /// as a consumer's test fake would. That this class compiles is the first half of the test. + /// + private sealed class InvoiceClient310Fake : IInvoiceClient + { + public List<(string Method, Invoice Invoice, IEnumerable Positions, CancellationToken Ct)> Calls { get; } = []; + + public Task SaveInvoiceAsync(Invoice invoice, IEnumerable positions, CancellationToken ct = default) + { + Calls.Add((nameof(SaveInvoiceAsync), invoice, positions, ct)); + return Task.FromResult(new Invoice { Id = 7 }); + } + + public Task SaveInvoiceReferenceAsync(Invoice invoice, IEnumerable positions, CancellationToken ct = default) + { + Calls.Add((nameof(SaveInvoiceReferenceAsync), invoice, positions, ct)); + return Task.FromResult(new SevDeskObjectReference { Id = 7, ObjectName = "Invoice" }); + } + + public Task> ListAsync(PaginationParameters? pagination = null, string? embed = null, InvoiceListFilter? filter = null, CancellationToken ct = default) => throw new NotImplementedException(); + public Task GetAsync(int id, string? embed = null, CancellationToken ct = default) => throw new NotImplementedException(); + public Task CreateAsync(Invoice invoice, CancellationToken ct = default) => throw new NotImplementedException(); + public Task UpdateAsync(int id, Invoice invoice, CancellationToken ct = default) => throw new NotImplementedException(); + public Task DeleteAsync(int id, CancellationToken ct = default) => throw new NotImplementedException(); + public Task ChangeStatusAsync(int id, InvoiceStatus status, CancellationToken ct = default) => throw new NotImplementedException(); + public Task GetPdfAsync(int id, CancellationToken ct = default) => throw new NotImplementedException(); + public Task SendViaEmailAsync(int id, string email, string subject, string text, CancellationToken ct = default) => throw new NotImplementedException(); + public Task DuplicateAsync(int id, CancellationToken ct = default) => throw new NotImplementedException(); + public Task CancelAsync(int id, CancellationToken ct = default) => throw new NotImplementedException(); + public Task MarkAsSentAsync(int id, CancellationToken ct = default) => throw new NotImplementedException(); + public Task BookAmountAsync(int id, decimal amount, int checkAccountId, DateTime date, CancellationToken ct = default) => throw new NotImplementedException(); + } + + [Fact] + public async Task Implementation310_SaveWithNullOrEmptyDiscounts_ForwardsToTheOverloadWithoutDiscounts() + { + var fake = new InvoiceClient310Fake(); + IInvoiceClient client = fake; + using var cts = new CancellationTokenSource(); + var invoice = SampleInvoice(); + var positions = SamplePositions(); + + (await client.SaveInvoiceAsync(invoice, positions, null, cts.Token)).Id.ShouldBe(7); + (await client.SaveInvoiceAsync(invoice, positions, [], cts.Token)).Id.ShouldBe(7); + (await client.SaveInvoiceReferenceAsync(invoice, positions, null, cts.Token)).Id.ShouldBe(7); + (await client.SaveInvoiceReferenceAsync(invoice, positions, [], cts.Token)).Id.ShouldBe(7); + + fake.Calls.Select(c => c.Method).ShouldBe(["SaveInvoiceAsync", "SaveInvoiceAsync", "SaveInvoiceReferenceAsync", "SaveInvoiceReferenceAsync"]); + foreach (var call in fake.Calls) + { + call.Invoice.ShouldBeSameAs(invoice); + call.Positions.ShouldBeSameAs(positions); + call.Ct.ShouldBe(cts.Token); + call.Ct.CanBeCanceled.ShouldBeTrue(); + } + } + + [Fact] + public async Task Implementation310_SaveWithDiscounts_ThrowsNotSupportedAndDoesNotSaveWithoutThem() + { + var fake = new InvoiceClient310Fake(); + IInvoiceClient client = fake; + + var ex = await Should.ThrowAsync( + () => client.SaveInvoiceAsync(SampleInvoice(), SamplePositions(), [Kundenrabatt()])); + await Should.ThrowAsync( + () => client.SaveInvoiceReferenceAsync(SampleInvoice(), SamplePositions(), [Kundenrabatt()])); + + ex.Message.ShouldContain("does not support document-level discounts"); + fake.Calls.ShouldBeEmpty(); + } + + [Fact] + public async Task Implementation310_GetDiscountsAsync_ThrowsNotSupported() + { + IInvoiceClient client = new InvoiceClient310Fake(); + + await Should.ThrowAsync(() => client.GetDiscountsAsync(1)); + } +} + From aa445429309a868bd9348c30a9cc58cde32c386d Mon Sep 17 00:00:00 2001 From: Timm Bremekamp Date: Tue, 29 Sep 2026 12:39:06 +0200 Subject: [PATCH 4/5] chore: deprecate CreateAsync where the API documents no POST endpoint Marks CreateAsync on the credit note, order, voucher, their position, category and check account clients as [Obsolete], pointing to the factory saves. Invoice, InvoicePos and Tag CreateAsync stay, with a note that their POST route is not in the specification. Extends the REST create request-body tests to Category, Tag and CheckAccount, and records the known limitations of the credit note, order and voucher saves in the changelog. --- CHANGELOG.md | 14 ++++++++++++++ src/sevDesk.NET/Clients/ICategoryClient.cs | 4 ++++ src/sevDesk.NET/Clients/ICheckAccountClient.cs | 6 ++++++ src/sevDesk.NET/Clients/ICreditNoteClient.cs | 5 +++++ src/sevDesk.NET/Clients/ICreditNotePosClient.cs | 5 +++++ src/sevDesk.NET/Clients/IInvoiceClient.cs | 4 ++++ src/sevDesk.NET/Clients/IInvoicePosClient.cs | 4 ++++ src/sevDesk.NET/Clients/IOrderClient.cs | 5 +++++ src/sevDesk.NET/Clients/IOrderPosClient.cs | 5 +++++ src/sevDesk.NET/Clients/ITagClient.cs | 4 ++++ src/sevDesk.NET/Clients/IVoucherClient.cs | 5 +++++ src/sevDesk.NET/Clients/IVoucherPosClient.cs | 5 +++++ tests/sevDesk.NET.Tests/CategoryClientTests.cs | 2 ++ .../CheckAccountClientTests.cs | 2 ++ .../sevDesk.NET.Tests/CreditNoteClientTests.cs | 2 ++ .../CreditNotePosClientTests.cs | 2 ++ .../FactoryRequestBodyTests.cs | 17 ++++++++++++----- tests/sevDesk.NET.Tests/OrderClientTests.cs | 2 ++ tests/sevDesk.NET.Tests/OrderPosClientTests.cs | 2 ++ tests/sevDesk.NET.Tests/VoucherClientTests.cs | 2 ++ .../sevDesk.NET.Tests/VoucherPosClientTests.cs | 2 ++ 21 files changed, 94 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef07b01..14fbecd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). [new DocumentDiscount { Text = "Kundenrabatt", IsPercentage = true, Value = 3 }]); ``` +### Deprecated + +- `CreateAsync` on `ICreditNoteClient`, `IOrderClient`, `IVoucherClient`, `ICreditNotePosClient`, `IOrderPosClient`, `IVoucherPosClient`, `ICategoryClient` and `ICheckAccountClient` is marked `[Obsolete]` (a warning, not an error). The sevDesk API documents no `POST` endpoint for any of these objects. Documents and their positions are created through the factory saves (`SaveCreditNoteAsync`, `SaveOrderAsync`, `SaveVoucherAsync`), check accounts through `CheckAccount/Factory/fileImportAccount` or `CheckAccount/Factory/clearingAccount`; for categories the API documents no way to create one. The methods keep working as before and will only be removed in a major version. +- Not deprecated, but not in the specification either: `POST /Invoice`, `POST /InvoicePos` and `POST /Tag`, behind `IInvoiceClient.CreateAsync`, `IInvoicePosClient.CreateAsync` and `ITagClient.CreateAsync`. They are accepted by the live API; their XML documentation now says that the route is undocumented. + ### Fixed - Factory saves (`Invoice`/`InvoicePos`) were rejected by the live API (400) because the body sent `"id":0` without `objectName`/`mapAll`. Every `SaveInvoiceAsync` and `SaveInvoiceReferenceAsync` call was affected, with or without positions: sevDesk answered `invoice expected array with 'id' and 'objectName'`, and no invoice was created. The `invoice` member of `Invoice/Factory/saveInvoice` now carries `"objectName":"Invoice"` and `"mapAll":true`, each entry of `invoicePosSave` carries `"objectName":"InvoicePos"`, and `id` is sent only when it is set — omitted for a new invoice or position, the real id for an existing one. `CreateAsync` and `UpdateAsync` do not send `objectName` or `mapAll`. @@ -34,6 +39,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Existing discounts of an invoice are not removed when it is saved again. `discountDelete` is not supported yet. - Credit notes are not covered: the sevDesk API documents the discount parameters of `CreditNote/Factory/saveCreditNote` as deprecated and without effect. +### Known limitations + +- The factory bodies are accepted by the live API for invoices, but they are not complete against the specification: fields it lists as required are sent only when the caller sets them, and some have no property at all. +- Credit note, order and voucher saves are not yet usable against the live API, because their models lack fields the API requires: + - `CreditNote`: `deliveryDate`, `addressCountry`, `taxRule`, `bookingCategory`, `invoiceType`. + - `Order`: `addressCountry`, `taxRule`, `version`. + - `Voucher`: `taxRule`. `VoucherPos` does not map `sumNet`/`sumGross` when writing and has no `accountDatev`, and `voucherPosSave` requires a reference to the voucher, which a new voucher does not have yet. +- The `Contacts.CreateAsync` fix is covered by tests, but not yet verified against the live API. + ## [3.1.0] - 2026-08-16 ### Added diff --git a/src/sevDesk.NET/Clients/ICategoryClient.cs b/src/sevDesk.NET/Clients/ICategoryClient.cs index 2ff71c4..17c2deb 100644 --- a/src/sevDesk.NET/Clients/ICategoryClient.cs +++ b/src/sevDesk.NET/Clients/ICategoryClient.cs @@ -32,6 +32,10 @@ public interface ICategoryClient /// The category to create. /// Cancellation token. /// The created category. + /// + /// The sevDesk API documents no POST /Category endpoint and no factory for categories. + /// + [Obsolete("The sevDesk API documents no POST /Category endpoint, and no other way to create a category.")] Task CreateAsync(Category category, CancellationToken ct = default); /// diff --git a/src/sevDesk.NET/Clients/ICheckAccountClient.cs b/src/sevDesk.NET/Clients/ICheckAccountClient.cs index 63743d8..b92d86b 100644 --- a/src/sevDesk.NET/Clients/ICheckAccountClient.cs +++ b/src/sevDesk.NET/Clients/ICheckAccountClient.cs @@ -31,6 +31,12 @@ public interface ICheckAccountClient /// The check account to create. /// Cancellation token. /// The created check account. + /// + /// The sevDesk API documents no POST /CheckAccount endpoint. Check accounts are created + /// through CheckAccount/Factory/fileImportAccount or + /// CheckAccount/Factory/clearingAccount, which this package does not cover yet. + /// + [Obsolete("The sevDesk API documents no POST /CheckAccount endpoint. Check accounts are created through CheckAccount/Factory/fileImportAccount or CheckAccount/Factory/clearingAccount.")] Task CreateAsync(CheckAccount account, CancellationToken ct = default); /// diff --git a/src/sevDesk.NET/Clients/ICreditNoteClient.cs b/src/sevDesk.NET/Clients/ICreditNoteClient.cs index c3c52c8..4508c8c 100644 --- a/src/sevDesk.NET/Clients/ICreditNoteClient.cs +++ b/src/sevDesk.NET/Clients/ICreditNoteClient.cs @@ -33,6 +33,11 @@ public interface ICreditNoteClient /// The credit note to create. /// Cancellation token. /// The created credit note. + /// + /// The sevDesk API documents no POST /CreditNote endpoint; credit notes are created through the + /// factory. Use instead. + /// + [Obsolete("The sevDesk API documents no POST /CreditNote endpoint. Use ICreditNoteClient.SaveCreditNoteAsync instead.")] Task CreateAsync(CreditNote creditNote, CancellationToken ct = default); /// diff --git a/src/sevDesk.NET/Clients/ICreditNotePosClient.cs b/src/sevDesk.NET/Clients/ICreditNotePosClient.cs index 9e131cc..735e109 100644 --- a/src/sevDesk.NET/Clients/ICreditNotePosClient.cs +++ b/src/sevDesk.NET/Clients/ICreditNotePosClient.cs @@ -31,6 +31,11 @@ public interface ICreditNotePosClient /// The credit note position to create. /// Cancellation token. /// The created credit note position. + /// + /// The sevDesk API documents no POST /CreditNotePos endpoint; credit note positions are created through the + /// factory. Use instead. + /// + [Obsolete("The sevDesk API documents no POST /CreditNotePos endpoint. Use ICreditNoteClient.SaveCreditNoteAsync instead.")] Task CreateAsync(CreditNotePos position, CancellationToken ct = default); /// diff --git a/src/sevDesk.NET/Clients/IInvoiceClient.cs b/src/sevDesk.NET/Clients/IInvoiceClient.cs index bbf7515..3de0663 100644 --- a/src/sevDesk.NET/Clients/IInvoiceClient.cs +++ b/src/sevDesk.NET/Clients/IInvoiceClient.cs @@ -53,6 +53,10 @@ Task> GetDiscountsAsync(int id, CancellationToke /// The invoice to create. /// Cancellation token. /// The created invoice. + /// + /// POST /Invoice is not part of the sevDesk API specification. It is accepted by the live + /// API, but undocumented; the documented way is the invoice factory, . + /// Task CreateAsync(Invoice invoice, CancellationToken ct = default); /// diff --git a/src/sevDesk.NET/Clients/IInvoicePosClient.cs b/src/sevDesk.NET/Clients/IInvoicePosClient.cs index 90bbbb3..46bbeba 100644 --- a/src/sevDesk.NET/Clients/IInvoicePosClient.cs +++ b/src/sevDesk.NET/Clients/IInvoicePosClient.cs @@ -31,6 +31,10 @@ public interface IInvoicePosClient /// The invoice position to create. /// Cancellation token. /// The created invoice position. + /// + /// POST /InvoicePos is not part of the sevDesk API specification. It is accepted by the live + /// API, but undocumented; the documented way is the invoice factory, . + /// Task CreateAsync(InvoicePos position, CancellationToken ct = default); /// diff --git a/src/sevDesk.NET/Clients/IOrderClient.cs b/src/sevDesk.NET/Clients/IOrderClient.cs index 040e1fc..f3f8b88 100644 --- a/src/sevDesk.NET/Clients/IOrderClient.cs +++ b/src/sevDesk.NET/Clients/IOrderClient.cs @@ -34,6 +34,11 @@ public interface IOrderClient /// The order to create. /// Cancellation token. /// The created order. + /// + /// The sevDesk API documents no POST /Order endpoint; orders are created through the + /// factory. Use instead. + /// + [Obsolete("The sevDesk API documents no POST /Order endpoint. Use IOrderClient.SaveOrderAsync instead.")] Task CreateAsync(Order order, CancellationToken ct = default); /// diff --git a/src/sevDesk.NET/Clients/IOrderPosClient.cs b/src/sevDesk.NET/Clients/IOrderPosClient.cs index bed847c..aa40832 100644 --- a/src/sevDesk.NET/Clients/IOrderPosClient.cs +++ b/src/sevDesk.NET/Clients/IOrderPosClient.cs @@ -31,6 +31,11 @@ public interface IOrderPosClient /// The order position to create. /// Cancellation token. /// The created order position. + /// + /// The sevDesk API documents no POST /OrderPos endpoint; order positions are created through the + /// factory. Use instead. + /// + [Obsolete("The sevDesk API documents no POST /OrderPos endpoint. Use IOrderClient.SaveOrderAsync instead.")] Task CreateAsync(OrderPos position, CancellationToken ct = default); /// diff --git a/src/sevDesk.NET/Clients/ITagClient.cs b/src/sevDesk.NET/Clients/ITagClient.cs index e1add23..210058a 100644 --- a/src/sevDesk.NET/Clients/ITagClient.cs +++ b/src/sevDesk.NET/Clients/ITagClient.cs @@ -30,6 +30,10 @@ public interface ITagClient /// The tag to create. /// Cancellation token. /// The created tag. + /// + /// POST /Tag is not part of the sevDesk API specification. It is accepted by the live + /// API, but undocumented; the documented way is Tag/Factory/create. + /// Task CreateAsync(Tag tag, CancellationToken ct = default); /// diff --git a/src/sevDesk.NET/Clients/IVoucherClient.cs b/src/sevDesk.NET/Clients/IVoucherClient.cs index 2df15df..881fe1d 100644 --- a/src/sevDesk.NET/Clients/IVoucherClient.cs +++ b/src/sevDesk.NET/Clients/IVoucherClient.cs @@ -33,6 +33,11 @@ public interface IVoucherClient /// The voucher to create. /// Cancellation token. /// The created voucher. + /// + /// The sevDesk API documents no POST /Voucher endpoint; vouchers are created through the + /// factory. Use instead. + /// + [Obsolete("The sevDesk API documents no POST /Voucher endpoint. Use IVoucherClient.SaveVoucherAsync instead.")] Task CreateAsync(Voucher voucher, CancellationToken ct = default); /// diff --git a/src/sevDesk.NET/Clients/IVoucherPosClient.cs b/src/sevDesk.NET/Clients/IVoucherPosClient.cs index aab5379..3107cd8 100644 --- a/src/sevDesk.NET/Clients/IVoucherPosClient.cs +++ b/src/sevDesk.NET/Clients/IVoucherPosClient.cs @@ -31,6 +31,11 @@ public interface IVoucherPosClient /// The voucher position to create. /// Cancellation token. /// The created voucher position. + /// + /// The sevDesk API documents no POST /VoucherPos endpoint; voucher positions are created through the + /// factory. Use instead. + /// + [Obsolete("The sevDesk API documents no POST /VoucherPos endpoint. Use IVoucherClient.SaveVoucherAsync instead.")] Task CreateAsync(VoucherPos position, CancellationToken ct = default); /// diff --git a/tests/sevDesk.NET.Tests/CategoryClientTests.cs b/tests/sevDesk.NET.Tests/CategoryClientTests.cs index e3db81f..78bca85 100644 --- a/tests/sevDesk.NET.Tests/CategoryClientTests.cs +++ b/tests/sevDesk.NET.Tests/CategoryClientTests.cs @@ -72,11 +72,13 @@ public async Task CreateAsync_ReturnsCreatedCategory() Content = JsonContent.Create(responseBody) }); +#pragma warning disable CS0618 // Obsolete: tests the deprecated member var result = await client.Categories.CreateAsync(new Category { Name = "New Category", ObjectType = "Contact" }); +#pragma warning restore CS0618 result.Id.ShouldBe(10); result.Name.ShouldBe("New Category"); diff --git a/tests/sevDesk.NET.Tests/CheckAccountClientTests.cs b/tests/sevDesk.NET.Tests/CheckAccountClientTests.cs index 718c5c7..ee9ba7f 100644 --- a/tests/sevDesk.NET.Tests/CheckAccountClientTests.cs +++ b/tests/sevDesk.NET.Tests/CheckAccountClientTests.cs @@ -73,11 +73,13 @@ public async Task CreateAsync_ReturnsCreatedCheckAccount() Content = JsonContent.Create(responseBody) }); +#pragma warning disable CS0618 // Obsolete: tests the deprecated member var result = await client.CheckAccounts.CreateAsync(new CheckAccount { Name = "Neues Konto", Currency = "EUR" }); +#pragma warning restore CS0618 result.Id.ShouldBe(10); result.Name.ShouldBe("Neues Konto"); diff --git a/tests/sevDesk.NET.Tests/CreditNoteClientTests.cs b/tests/sevDesk.NET.Tests/CreditNoteClientTests.cs index 03fa537..9feecd7 100644 --- a/tests/sevDesk.NET.Tests/CreditNoteClientTests.cs +++ b/tests/sevDesk.NET.Tests/CreditNoteClientTests.cs @@ -78,10 +78,12 @@ public async Task CreateAsync_ReturnsCreatedCreditNote() Content = JsonContent.Create(responseBody) }); +#pragma warning disable CS0618 // Obsolete: tests the deprecated member var result = await client.CreditNotes.CreateAsync(new CreditNote { CreditNoteNumber = "GS-020" }); +#pragma warning restore CS0618 result.Id.ShouldBe(20); result.CreditNoteNumber.ShouldBe("GS-020"); diff --git a/tests/sevDesk.NET.Tests/CreditNotePosClientTests.cs b/tests/sevDesk.NET.Tests/CreditNotePosClientTests.cs index 929faa6..d0cb72a 100644 --- a/tests/sevDesk.NET.Tests/CreditNotePosClientTests.cs +++ b/tests/sevDesk.NET.Tests/CreditNotePosClientTests.cs @@ -71,12 +71,14 @@ public async Task CreateAsync_ReturnsCreatedCreditNotePosition() Content = JsonContent.Create(responseBody) }); +#pragma warning disable CS0618 // Obsolete: tests the deprecated member var result = await client.CreditNotePositions.CreateAsync(new CreditNotePos { Name = "New CN Pos", Quantity = 1, Price = 30.00m }); +#pragma warning restore CS0618 result.Id.ShouldBe(10); result.Name.ShouldBe("New CN Pos"); diff --git a/tests/sevDesk.NET.Tests/FactoryRequestBodyTests.cs b/tests/sevDesk.NET.Tests/FactoryRequestBodyTests.cs index c8cbdc0..0e71c3b 100644 --- a/tests/sevDesk.NET.Tests/FactoryRequestBodyTests.cs +++ b/tests/sevDesk.NET.Tests/FactoryRequestBodyTests.cs @@ -9,9 +9,11 @@ namespace sevDesk.NET.Tests; /// -/// The request bodies sent to the Factory save endpoints, checked against the fields the -/// sevDesk API requires. A body with "id":0 and without objectName/mapAll -/// is rejected by the live API with 400. +/// The request bodies sent to the Factory save endpoints. A body with "id":0 and +/// without objectName/mapAll is rejected by the live API with 400. The fixed body is +/// accepted live, but still omits fields the specification lists as required +/// (addressCountry, contactPerson, taxRule, taxText) when the caller +/// does not set them. /// public class FactoryRequestBodyTests { @@ -61,7 +63,7 @@ private static InvoicePos[] NewPositions(int firstId = 0) => .Replace("ü", "\\u00FC"); [Fact] - public async Task SaveInvoice_NewInvoice_SendsTheSpecConformantBody() + public async Task SaveInvoice_NewInvoice_SendsTheBodyTheLiveApiAccepts() { var (client, handler) = CreateClient(Json(HttpStatusCode.OK, FactoryResponse)); @@ -230,7 +232,7 @@ public async Task ContactUpdate_SendsTheRealId() body.RootElement.GetProperty("id").GetInt32().ShouldBe(7); } - public static TheoryData RestCreates => ["Part", "CommunicationWay", "ContactAddress", "CheckAccountTransaction"]; + public static TheoryData RestCreates => ["Part", "CommunicationWay", "ContactAddress", "CheckAccountTransaction", "Category", "Tag", "CheckAccount"]; [Theory] [MemberData(nameof(RestCreates))] @@ -244,6 +246,11 @@ public async Task RestCreate_NewObject_SendsNoId(string objectName) "CommunicationWay" => (Task)client.CommunicationWays.CreateAsync(new CommunicationWay { Value = "mail@example.com" }), "ContactAddress" => client.ContactAddresses.CreateAsync(new ContactAddress { City = "Münster" }), "CheckAccountTransaction" => client.CheckAccountTransactions.CreateAsync(new CheckAccountTransaction { Amount = 1 }), +#pragma warning disable CS0618 // Obsolete: the request body is still tested + "Category" => client.Categories.CreateAsync(new Category { Name = "Category" }), + "CheckAccount" => client.CheckAccounts.CreateAsync(new CheckAccount { Name = "Account" }), +#pragma warning restore CS0618 + "Tag" => client.Tags.CreateAsync(new Tag { Name = "Tag" }), var other => throw new ArgumentOutOfRangeException(nameof(objectName), other, null) }); diff --git a/tests/sevDesk.NET.Tests/OrderClientTests.cs b/tests/sevDesk.NET.Tests/OrderClientTests.cs index de9f081..6cd2210 100644 --- a/tests/sevDesk.NET.Tests/OrderClientTests.cs +++ b/tests/sevDesk.NET.Tests/OrderClientTests.cs @@ -79,11 +79,13 @@ public async Task CreateAsync_ReturnsCreatedOrder() Content = JsonContent.Create(responseBody) }); +#pragma warning disable CS0618 // Obsolete: tests the deprecated member var result = await client.Orders.CreateAsync(new Order { OrderNumber = "AN-010", Currency = "EUR" }); +#pragma warning restore CS0618 result.Id.ShouldBe(10); result.OrderNumber.ShouldBe("AN-010"); diff --git a/tests/sevDesk.NET.Tests/OrderPosClientTests.cs b/tests/sevDesk.NET.Tests/OrderPosClientTests.cs index 478c81e..4fb7089 100644 --- a/tests/sevDesk.NET.Tests/OrderPosClientTests.cs +++ b/tests/sevDesk.NET.Tests/OrderPosClientTests.cs @@ -72,12 +72,14 @@ public async Task CreateAsync_ReturnsCreatedOrderPosition() Content = JsonContent.Create(responseBody) }); +#pragma warning disable CS0618 // Obsolete: tests the deprecated member var result = await client.OrderPositions.CreateAsync(new OrderPos { Name = "New Order Pos", Quantity = 2, Price = 50.00m }); +#pragma warning restore CS0618 result.Id.ShouldBe(10); result.Name.ShouldBe("New Order Pos"); diff --git a/tests/sevDesk.NET.Tests/VoucherClientTests.cs b/tests/sevDesk.NET.Tests/VoucherClientTests.cs index 4f05e21..e8e3b41 100644 --- a/tests/sevDesk.NET.Tests/VoucherClientTests.cs +++ b/tests/sevDesk.NET.Tests/VoucherClientTests.cs @@ -80,11 +80,13 @@ public async Task CreateAsync_ReturnsCreatedVoucher() Content = JsonContent.Create(responseBody) }); +#pragma warning disable CS0618 // Obsolete: tests the deprecated member var result = await client.Vouchers.CreateAsync(new Voucher { Description = "New Voucher", CreditDebit = "D" }); +#pragma warning restore CS0618 result.Id.ShouldBe(20); result.Description.ShouldBe("New Voucher"); diff --git a/tests/sevDesk.NET.Tests/VoucherPosClientTests.cs b/tests/sevDesk.NET.Tests/VoucherPosClientTests.cs index 5253cf4..128db3b 100644 --- a/tests/sevDesk.NET.Tests/VoucherPosClientTests.cs +++ b/tests/sevDesk.NET.Tests/VoucherPosClientTests.cs @@ -72,11 +72,13 @@ public async Task CreateAsync_ReturnsCreatedVoucherPosition() Content = JsonContent.Create(responseBody) }); +#pragma warning disable CS0618 // Obsolete: tests the deprecated member var result = await client.VoucherPositions.CreateAsync(new VoucherPos { Net = 75.00m, TaxRate = 19 }); +#pragma warning restore CS0618 result.Id.ShouldBe(10); result.Net.ShouldBe(75.00m); From b333a50b683228924a4c26b05c1074dd63cbb975 Mon Sep 17 00:00:00 2001 From: Timm Bremekamp Date: Tue, 29 Sep 2026 12:40:57 +0200 Subject: [PATCH 5/5] ci: run dotnet test on Microsoft.Testing.Platform Since Microsoft.Testing.Platform 2.3.3, dotnet test on the .NET 10 SDK refuses the VSTest target, which has kept CI red on main and would fail the test step of the release workflow. Opt in via global.json; the workflow commands stay unchanged. --- CONTRIBUTING.md | 2 +- global.json | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7882055..12c0979 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ dotnet build ### Run Tests ```bash -dotnet test tests/sevDesk.NET.Tests/ +dotnet test --project tests/sevDesk.NET.Tests/ ``` ## Pull Request Guidelines diff --git a/global.json b/global.json index 0860802..4babe73 100644 --- a/global.json +++ b/global.json @@ -2,5 +2,8 @@ "sdk": { "version": "10.0.100", "rollForward": "latestMinor" + }, + "test": { + "runner": "Microsoft.Testing.Platform" } }