Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<DocumentDiscount>? 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
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
</PropertyGroup>

<PropertyGroup>
<VersionPrefix>3.1.0</VersionPrefix>
<VersionPrefix>3.2.0</VersionPrefix>
<Authors>sevDesk.NET Contributors</Authors>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/emuuu/sevDesk.NET</PackageProjectUrl>
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<DocumentDiscount> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 3 additions & 0 deletions global.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@
"sdk": {
"version": "10.0.100",
"rollForward": "latestMinor"
},
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
2 changes: 1 addition & 1 deletion src/sevDesk.NET/Clients/CreditNoteClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ private Task<FactoryWriteResult> 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,
Expand Down
4 changes: 4 additions & 0 deletions src/sevDesk.NET/Clients/ICategoryClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ public interface ICategoryClient
/// <param name="category">The category to create.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The created category.</returns>
/// <remarks>
/// The sevDesk API documents no <c>POST /Category</c> endpoint and no factory for categories.
/// </remarks>
[Obsolete("The sevDesk API documents no POST /Category endpoint, and no other way to create a category.")]
Task<Category> CreateAsync(Category category, CancellationToken ct = default);

/// <summary>
Expand Down
6 changes: 6 additions & 0 deletions src/sevDesk.NET/Clients/ICheckAccountClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ public interface ICheckAccountClient
/// <param name="account">The check account to create.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The created check account.</returns>
/// <remarks>
/// The sevDesk API documents no <c>POST /CheckAccount</c> endpoint. Check accounts are created
/// through <c>CheckAccount/Factory/fileImportAccount</c> or
/// <c>CheckAccount/Factory/clearingAccount</c>, which this package does not cover yet.
/// </remarks>
[Obsolete("The sevDesk API documents no POST /CheckAccount endpoint. Check accounts are created through CheckAccount/Factory/fileImportAccount or CheckAccount/Factory/clearingAccount.")]
Task<CheckAccount> CreateAsync(CheckAccount account, CancellationToken ct = default);

/// <summary>
Expand Down
5 changes: 5 additions & 0 deletions src/sevDesk.NET/Clients/ICreditNoteClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ public interface ICreditNoteClient
/// <param name="creditNote">The credit note to create.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The created credit note.</returns>
/// <remarks>
/// The sevDesk API documents no <c>POST /CreditNote</c> endpoint; credit notes are created through the
/// factory. Use <see cref="SaveCreditNoteAsync(CreditNote, IEnumerable{CreditNotePos}, CancellationToken)"/> instead.
/// </remarks>
[Obsolete("The sevDesk API documents no POST /CreditNote endpoint. Use ICreditNoteClient.SaveCreditNoteAsync instead.")]
Task<CreditNote> CreateAsync(CreditNote creditNote, CancellationToken ct = default);

/// <summary>
Expand Down
5 changes: 5 additions & 0 deletions src/sevDesk.NET/Clients/ICreditNotePosClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ public interface ICreditNotePosClient
/// <param name="position">The credit note position to create.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The created credit note position.</returns>
/// <remarks>
/// The sevDesk API documents no <c>POST /CreditNotePos</c> endpoint; credit note positions are created through the
/// factory. Use <see cref="ICreditNoteClient.SaveCreditNoteAsync(CreditNote, IEnumerable{CreditNotePos}, CancellationToken)"/> instead.
/// </remarks>
[Obsolete("The sevDesk API documents no POST /CreditNotePos endpoint. Use ICreditNoteClient.SaveCreditNoteAsync instead.")]
Task<CreditNotePos> CreateAsync(CreditNotePos position, CancellationToken ct = default);

/// <summary>
Expand Down
Loading
Loading