diff --git a/CHANGELOG.md b/CHANGELOG.md index 24a6b63..14fbecd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,50 @@ 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/). +## [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 }]); + ``` + +### 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`. +- 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. + +### 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/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/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/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" } } 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/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 9631bf1..3de0663 100644 --- a/src/sevDesk.NET/Clients/IInvoiceClient.cs +++ b/src/sevDesk.NET/Clients/IInvoiceClient.cs @@ -29,12 +29,34 @@ 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. /// /// 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); /// @@ -76,6 +98,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 +160,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 +252,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/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/src/sevDesk.NET/Clients/InvoiceClient.cs b/src/sevDesk.NET/Clients/InvoiceClient.cs index 1a21ca6..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.ToApi(invoice), - InvoicePosSave = positions.Select(ModelMapper.ToApi).ToList() + Invoice = ModelMapper.ToApiFactory(invoice), + 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/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/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/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/ApiInvoiceModels.cs b/src/sevDesk.NET/Internal/ApiModels/ApiInvoiceModels.cs index f08ad1c..33c1981 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; } @@ -96,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; } @@ -156,13 +178,28 @@ 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 { + /// + /// 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/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 cbb76a7..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() @@ -206,6 +210,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,9 +262,33 @@ internal static class ModelMapper Text = model.Text, Discount = model.Discount, Optional = FormatBool(model.Optional), + ObjectName = "InvoicePos", 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() @@ -306,6 +346,18 @@ internal static class ModelMapper 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() @@ -343,6 +395,7 @@ internal static class ModelMapper Text = model.Text, Discount = model.Discount, Optional = FormatBool(model.Optional), + ObjectName = "OrderPos", MapAll = true }; @@ -388,6 +441,18 @@ internal static class ModelMapper 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() @@ -417,6 +482,7 @@ internal static class ModelMapper TaxRate = model.TaxRate, IsAsset = FormatBool(model.IsAsset), Comment = model.Comment, + ObjectName = "VoucherPos", MapAll = true }; @@ -469,6 +535,18 @@ internal static class ModelMapper 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() @@ -506,6 +584,7 @@ internal static class ModelMapper Text = model.Text, Discount = model.Discount, Optional = FormatBool(model.Optional), + ObjectName = "CreditNotePos", MapAll = true }; 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/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 new file mode 100644 index 0000000..0e71c3b --- /dev/null +++ b/tests/sevDesk.NET.Tests/FactoryRequestBodyTests.cs @@ -0,0 +1,261 @@ +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. 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 +{ + 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_SendsTheBodyTheLiveApiAccepts() + { + 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(); + } + } +} + +/// +/// 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", "Category", "Tag", "CheckAccount"]; + + [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 }), +#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) + }); + + handler.Requests[0].Uri!.AbsolutePath.ShouldBe("/api/v1/" + objectName); + using var body = JsonDocument.Parse(handler.Requests[0].Body!); + body.RootElement.TryGetProperty("id", 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}."); + } +} 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)); + } +} + 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);