DevOp.DK.Plus
0.5.0
dotnet add package DevOp.DK.Plus --version 0.5.0
NuGet\Install-Package DevOp.DK.Plus -Version 0.5.0
<PackageReference Include="DevOp.DK.Plus" Version="0.5.0" />
<PackageVersion Include="DevOp.DK.Plus" Version="0.5.0" />
<PackageReference Include="DevOp.DK.Plus" />
paket add DevOp.DK.Plus --version 0.5.0
#r "nuget: DevOp.DK.Plus, 0.5.0"
#:package DevOp.DK.Plus@0.5.0
#addin nuget:?package=DevOp.DK.Plus&version=0.5.0
#tool nuget:?package=DevOp.DK.Plus&version=0.5.0
DevOp.DK.Plus
A typed .NET 10 client for DK Plus API v1 and v2 with DevOp-owned DTOs, structured logging, fluent incremental queries, and support for multiple configured ERP connections.
Install
dotnet add package DevOp.DK.Plus
The provider-neutral DevOp.ERP.Abstractions dependency is installed automatically.
Manual creation without dependency injection
For a console application, desktop application, test, or small integration, create one long-lived client directly:
using DevOp.DK.Plus;
using var client = DkPlusClient.Create(new DkPlusClientOptions
{
ApiToken = token
});
var company = await client.Company.GetSettingsAsync();
Only ApiToken is required. The base URL, versioned user agent, current-UI-culture language, timeout, page size, and retry settings have defaults. The returned client owns its HTTP transport and must be disposed. Reuse it for related operations instead of creating one client per request.
Optional logging uses a caller-owned ILoggerFactory:
using var client = DkPlusClient.Create(options, loggerFactory);
The manual transport disables redirects and cookies, refreshes pooled connections periodically, validates options before use, and uses the same Microsoft HTTP resilience profile as dependency injection: safe-method retries with exponential jitter, bounded Retry-After, attempt and total timeouts, concurrency limiting, and circuit breaking. Mutations are never retried automatically.
Multi-tenant dependency injection
Applications that own DK Plus credentials in a database or secret store should register the context-aware client:
using DevOp.DK.Plus.Extensions;
using DevOp.ERP;
services.AddDkPlus();
services.AddScoped<IDkPlusContextProvider, ApplicationDkPlusContextProvider>();
services.AddScoped<IDkPlusTokenProvider, DatabaseDkPlusTokenProvider>();
The application supplies an immutable DkPlusContext and asynchronously returns credentials for that exact tenant, company, and optional user. The library never accesses the application database.
public async ValueTask<DkPlusCredentials> GetCredentialsAsync(
DkPlusContext context,
CancellationToken cancellationToken)
{
var connection = await FindAuthorizedConnectionAsync(context, cancellationToken)
?? throw new DkPlusConnectionNotFoundException(context);
// BaseUrl defaults to https://api.dkplus.is.
return new DkPlusCredentials(
Decrypt(connection.AccessToken),
expiresAt: connection.ExpiresAt);
}
Application services use normal constructor injection. Credentials are resolved per operation and attached only to that request:
public sealed class CustomerImport(IExternalErpClient erp)
{
public Task<IReadOnlyList<Customer>> ReadAsync(CancellationToken cancellationToken) =>
erp.Customer.Query()
.ModifiedSince(DateTimeOffset.UtcNow.AddDays(-1))
.ToListAsync(cancellationToken);
}
When DK Plus-specific response shaping is useful, inject IDkPlusClient and select fields by public model property name. IDkPlusClient returns the sealed DkPlus<Entity> subtype of every portable model (DkPlusCustomer : Customer), and DK-only members such as the VAT number, ISAT code, gender, VAT exemption, and receivables account live on the subtype:
var customers = await client.Customer.Query()
.Include(
nameof(DkPlusCustomer.Number),
nameof(DkPlusCustomer.VatNumber),
nameof(DkPlusCustomer.IndustryClassificationCode),
nameof(DkPlusCustomer.Gender),
nameof(DkPlusCustomer.VatExempt),
nameof(DkPlusCustomer.ReceivablesAccount))
.Exclude(nameof(DkPlusCustomer.Blocked))
.ToListAsync(cancellationToken);
The client maps public names to DK Plus wire fields and validates them before sending a request. Customer, vendor, product, and project v1 responses have complete typed top-level coverage. Related values are organized into domain objects such as DkPlusCustomer.Credit, product pricing and inventory settings, and project schedule, requirements, and billing settings. Provider spellings such as Recivers, AllowNegativeInventiry, and CustomerToRecieve stay internal. Required identity fields (Number and ItemCode) are added automatically when omitted from Include and cannot be excluded; paged queries likewise always select their paging timestamp (ObjectDate for every entity whose DK Plus list response has it, including customer transactions, which also force their Id) so they can continue past the 50,000-record limit. See Doc/Queries.md for projection behavior and Doc/ModelCoverage.md for the audited typed-model matrix.
A portable consumer that receives these objects through IExternalErpClient holds the same instances typed as the portable base. JsonSerializer.Serialize<Customer>(value) writes only the portable members, but serializers that use the runtime type, such as ASP.NET Core output formatters and structured loggers (Serilog {@Customer}), write every DkPlus* member, including contact personal data and accounting codes. Project the results to your own DTOs before returning or logging them.
Background jobs use an explicit immutable context:
var client = clientFactory.CreateClient(
new DkPlusContext(tenantId, companyId, userId: null));
IDkPlusClient, IExternalErpClient, their providers, and IDkPlusClientFactory are scoped. Create a new DI scope for each background unit of work.
Static single connection
For one startup-known token, the existing static registration remains available. Only ApiToken is required:
{
"DkPlus": {
"ApiToken": "set-through-a-secret-provider"
}
}
services.AddDkPlusClient(configuration, sectionName: "DkPlus");
BaseUrl defaults to https://api.dkplus.is. User agent, UI language, page size, and resilience settings also have defaults and should be omitted unless overridden. The versioned DevOp-DK-Client/<version> user agent is automatic. An omitted language follows CultureInfo.CurrentUICulture, with neutral English as fallback.
Eligible safe requests retry up to three times by default with exponential jitter. HTTP 429 honors server guidance up to the configured five-second maximum. Mutations are never retried automatically. The default attempt timeout is ten seconds and the total operation budget is thirty seconds. Configure these through options.Resilience; Polly is not part of the public configuration API. Tokens belong in an application credential provider, environment secret, secret manager, or .NET user secrets—never committed configuration.
Portable and DK-specific APIs
Use IExternalErpClient for code that should work with DK Plus, Regla, Payday, or another future provider:
var invoices = await erp.Sales.Invoice.Query()
.ForCustomer("C-100")
.ModifiedSince(lastRun)
.ToListAsync(cancellationToken);
Modified on the portable models is the provider's modification time, and ModifiedSince/ModifiedBefore use the provider's change-tracking time, which may differ. For DK Plus the DkPlus* subtypes expose ObjectDate (label "Stored Date"), the dkPlus stored time, and DK Plus pages and applies modified-since/before filters on ObjectDate when the entity has one, otherwise on Modified; the client only forwards the window.
Use IDkPlusClient for DK-specific operations:
await client.Sales.Order.SendToInvoiceAsync(orderId, cancellationToken);
var entry = await client.Nation.GetAsync(nationalId, cancellationToken);
var companyStatus = await client.Company.GetStatusAsync(cancellationToken);
var invoicePdf = await client.Sales.Invoice.GetPdfAsync("INV-100", cancellationToken: cancellationToken);
var orderPdf = await client.Sales.Order.GetPdfAsync(123, cancellationToken: cancellationToken);
var quoteHtml = await client.Sales.Quote.GetHtmlAsync(456, cancellationToken);
var webhooks = await client.Webhook.GetAllAsync(cancellationToken);
var permissions = await client.Permission.GetCurrentAsync(cancellationToken);
var vendors = await client.Purchase.Vendor.SearchAsync("Example supplier", cancellationToken: cancellationToken);
var vendorInvoices = await client.Purchase.Invoice.GetMyUnapprovedAsync(cancellationToken);
var warehouses = await client.Warehouse.GetAllAsync(cancellationToken);
var customerTransactions = await client.Customer.Transactions.Query()
.ForCustomer("C-100")
.Take(100)
.ToListAsync(cancellationToken);
var ledgerAccounts = await client.GeneralLedger.Account.GetAllAsync(cancellationToken: cancellationToken);
var currencies = await client.Currency.Query().ToListAsync(cancellationToken);
var paymentTerms = await client.Payment.Term.Query().ToListAsync(cancellationToken);
var productUnits = await client.Product.Unit.Query().ToListAsync(cancellationToken);
var tokenLogs = await client.Token.GetRequestLogsAsync(cancellationToken: cancellationToken);
The DK-specific client uses singular root resources and groups related operations under Sales, Purchase, GeneralLedger, and Project. Customer transactions are exposed as Customer.Transactions; customer contacts as Customer.Contact; customer item receivers as Customer.Receiver; customer groups as Customer.Group; and product groups, categories, and units under Product. Mutating operations use DevOp-owned request models rather than Swagger-generated types.
Queries continue past DK Plus's limit of 50,000 records per query by restarting at page 1 with a modified-after window anchored on the last record's paging field (ObjectDate when the entity's DK Plus list response has it, otherwise Modified), returning each record once; see Doc/Queries.md for when a query asks for a narrower window instead. ToPageAsync continuation tokens are opaque, resume only the same query and filters, and may contain timestamps and hashed record identities, so do not log them.
Product queries exclude malformed DK Plus rows whose ItemCode is null, empty, or whitespace and emit a structured warning containing a bounded list of the affected record IDs. Paging continues according to the unfiltered DK Plus page size, so an invalid row cannot hide valid products from later pages.
Nation lookups use the same provider-neutral Address model as customers and vendors. Its Lines property preserves the ordered street-address lines, while PostalCode, City, and CountryCode remain structured fields.
Create a customer without exposing DK Plus transport DTOs, then add its contacts and delivery receivers through Customer.Contact and Customer.Receiver. DK Plus ignores contacts, receivers, and memos sent in the customer document, so the customer requests have no Contacts, Receivers, or Memos member; customer memos have no write endpoint and are read-only:
await client.Customer.CreateAsync(new CustomerCreateRequest
{
Number = "C-100",
Name = "Example customer",
Group = "ALM",
PaymentTerm = "14D",
PaymentMode = "BANK"
}, cancellationToken);
CustomerContact? created = await client.Customer.Contact.CreateAsync("C-100", new CustomerContactCreateRequest
{
Number = "1",
Name = "Accounts",
Email = "accounts@example.com"
}, cancellationToken);
await client.Customer.Contact.UpdateAsync("C-100", "1", new CustomerContactUpdateRequest { Title = "CFO" }, cancellationToken);
IReadOnlyList<CustomerContact> contacts = await client.Customer.Contact.GetAllAsync("C-100", cancellationToken);
CustomerReceiver? receiver = await client.Customer.Receiver.CreateAsync("C-100", new CustomerReceiverCreateRequest
{
Number = "MAIN",
Name = "Main delivery"
}, cancellationToken);
await client.Customer.Receiver.UpdateAsync("C-100", "MAIN", new CustomerReceiverUpdateRequest { Phone = "5551234" }, cancellationToken);
IReadOnlyList<CustomerReceiver> receivers = await client.Customer.Receiver.GetAllAsync("C-100", cancellationToken);
Customer.Contact also has GetAsync(customer, contactId) (null when missing) and DeleteAsync(customer, contactId). Swagger does not say which contact field the contact id matches; the contact number is the likely candidate but is not verified live.
Note for the example above: the contact id is the contact number, and DK Plus reads lag writes by a few seconds, so a read right after a create or update may not show the change yet (observed live 2026-10-11). A contact or receiver update that sets no member throws ArgumentException before any request.
Customer.Receiver has the same shape (GetAsync returns null when missing; UpdateAsync returns no document because DK Plus declares none), plus GetByNumberAsync(receiverNumber), which finds the receivers with a number across customers (CustomerReceiver.Customer names the owner), and GetCustomersAsync(receiverNumber), which returns the customers that use it. Which receiver field the receiver id matches is likewise not verified live.
Update only the intended DK Plus customer fields with a sparse request. The customer number is the path argument. Null members and unspecified collections are omitted rather than serialized as null or empty defaults; an empty string is sent verbatim to clear a text field:
await client.Customer.UpdateAsync("abcd", new CustomerUpdateRequest
{
Address = new Address { Lines = ["Móstekkur 47"] },
Alias = ""
}, cancellationToken);
Every entity with a write endpoint follows this pattern: {Entity}CreateRequest for the full document and {Entity}UpdateRequest for a sparse update, for customers, customer contacts, customer item receivers, vendors, products, projects, phases, tasks, work requests, sales orders, quotes, recurring sales, and sales people. General-ledger accounts and tax groups have create endpoints only and use GeneralLedgerAccountCreateRequest and GeneralLedgerTaxGroupCreateRequest (before 0.4.0 they took the read models and sent Modified). These writes never accept a read model, including nested rows: product barcodes, warehouses (and their variations), units, and vendors use ProductBarcodeRequest, ProductWarehouseSettingsRequest (ProductVariationRequest), ProductUnitConversionRequest, and ProductVendorRequest, and vendor and product memos use MemoPageRequest (customer memos are read-only), so stock quantities, purchase history, and nested modification times are never sent. The single-warehouse update client.Warehouse.UpdateProductAsync(warehouse, product, request) takes the same ProductWarehouseSettingsRequest (it took the ProductWarehouse read model before 0.4.0; that type and WarehouseProductVariation are removed); change stock through client.Inventory journals or counts. DK Plus clearing a field sent as an empty string has been observed live only for the customer Alias, and whether a supplied collection replaces or merges the stored one is not verified live; see Doc/DkPlusApi.md. DK Plus has no project delete endpoint: client.Project.DeleteAsync(number) and DeleteByRecordIdAsync(recordId) delete through the generic table endpoint on the dkERP project table pjjob, which skips DK Plus project validation; their effect on a project with transactions, phases, or work requests is not verified live.
Invoice writes use SalesInvoiceCreateRequest rather than reusing the portable invoice read model:
var request = new SalesInvoiceCreateRequest
{
Customer = new DkPlusSalesDocumentParty { Number = "C-100" },
SalesPerson = "S-1",
Lines = [new SalesInvoiceLineRequest { ItemCode = "ITEM-1", Quantity = 2 }]
};
var calculated = await client.Sales.Invoice.CalculatePricesAsync(request, cancellationToken);
Order, quote, and recurring-sale creates with Lines post the head first and then each line. Known DK Plus issue: DK Plus can create a v2 quote head without a dkERP id (Id 0, no Number); Sales.Quote.CreateAsync with lines then sends no line and no delete and throws InvalidOperationException naming the quote UId (the head may not be deletable), see Doc/DkPlusApi.md.
Portable filters that a resource cannot translate fail with NotSupportedException; they are never silently ignored. Use AsAsyncEnumerable to consume large queries one page at a time. Take remains a total result limit without changing the provider page size between page requests.
Errors and logging
Outgoing JSON models are recursively validated against implemented DK Plus OpenAPI constraints before serialization. Invalid requests throw DkPlusModelValidationException, expose errors by property path, and never reach the network.
Every outbound operation carries an X-Correlation-Id in the form devop_<32 lowercase hex characters>. The client reuses the current W3C activity trace ID when available and otherwise generates a GUID; retries for the same operation keep the same value. Unsuccessful responses throw DkPlusRequestException, which exposes the HTTP status, operation, method, query-free request URI, effective correlation ID, and a bounded response body. A correlation ID returned by DK Plus takes precedence, while the sent value is retained when the response omits it. A successful response whose JSON is incompatible with the documented contract throws DkPlusContractException with the operation, expected type, JSON path, and safe endpoint, but never the response body. HTTP 429 uses DkPlusRateLimitException and includes RetryAfter when available. Resilience timeouts, open circuits, and local concurrency rejection use DkPlusTimeoutException, DkPlusCircuitOpenException, and DkPlusConcurrencyLimitException. Contextual setup can also raise specific missing-context, missing-connection, authentication, or endpoint-authorization exceptions. Treat ResponseBody as sensitive and do not log it without review.
The client uses ILogger<T> throughout. It logs operations, status codes, timing, paging, and retry decisions, but never logs authorization headers, credentials, request/response bodies, or token path values.
See the repository Doc/ folder for the complete configuration, query, logging, and testing guides.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- DevOp.ERP.Abstractions (>= 0.5.0)
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.12)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Http (>= 10.0.12)
- Microsoft.Extensions.Http.Resilience (>= 10.10.0)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Options (>= 10.0.12)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.12)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
v0.5.0: client.Customer.Query() returns the new ICustomerQuery and client.Product.Query() the new IProductQuery, typed server-side filters on top of IDkPlusQuery<T>: customers ForName, ForSalesPerson, ForCountry, ForPostalCode, ForPaymentTerm, ForPaymentMode, ForEmail, Blocked, VatExempt and Deleted (wire name, salesperson, country, zipcode, paymentterm, paymentmode, email, blocked, novat, deleted); products ForAliasItemCode, Inactive, OnWeb and Deleted (wire aliasItemCode, inactive, onweb, deleted). The client normalizes the text before sending, because DK Plus matches the customer group, name and email only in lowercase: the customer and product group (ForGroup), name, sales person, postal code, payment term, payment mode and email are sent lowercase and the country uppercase (invariant culture), and a name without * is sent as *name* (a contains search; DK Plus ignores a name without a wildcard). Live, letter country codes such as IS match no customers. Every fluent member of the new self-typed IDkPlusQuery<T, TSelf> returns the entity query, so filters, Take, Include and Exclude chain in any order. The filters are sent after the existing parameters in wire-name order, kept on every page and after a re-anchor past the 50,000-record limit, and part of the continuation-token filter hash; a blank string filter throws ArgumentException before sending, and a second call replaces the value. Match semantics are DK Plus's own and not yet verified live (products document * as a wildcard). The new static DkPlusProjections lists, per model type, the property names Include and Exclude accept (IsSupported, IncludableProperties, ExcludableProperties; exclude-only payroll names such as SpouseName are excludable only). Callers are binary-compatible; a var reassigned from a helper that returns IDkPlusQuery<T> needs an explicit type, and only custom implementations of IDkPlusCustomerResource or IDkPlusProductResource must add the new Query() member. IExternalErpClient is unchanged. Project.DeleteAsync(number) and Project.DeleteByRecordIdAsync(recordId) delete a project through the generic table endpoint on the dkERP project table pjjob (DELETE /api/v1/general/table/pjjob?key=Number or RecordId&value=...), because DK Plus has no project delete endpoint. The maintainer confirmed that the table name and both key names work. They bypass DK Plus project validation, validate the number or record id before sending, and are never retried; what dkERP does with a project that has transactions, phases or work requests is not verified live. v0.4.2: SalesInvoiceCreateRequest.SalesPerson is now an optional string? without [Required] or the required modifier (pre-1.0, no shim). When it is null the member is left out of the invoice body and DK Plus uses the sales person of the employee linked to the API user; before, a missing salesperson was rejected before sending. v0.4.2: Breaking: VendorInvoiceApprovalRequest.ApprovalAction is now int? with [Required], and UpdateApprovalAsync validates the request before sending. A request that omits ApprovalAction (an unset property, a JSON file or a hashtable without it) is rejected with DkPlusModelValidationException naming ApprovalAction and nothing is sent; before, the missing value was silently sent as ApprovalAction 0 on a high-impact approval. Callers that assigned an int still compile; code that read the property as an int must now handle null. v0.4.0: Breaking: Contacts is removed from CustomerCreateRequest and CustomerUpdateRequest because DK Plus ignores contacts in the customer document (a create or update with contacts stored none); contacts are maintained through the new client.Customer.Contact resource (IDkPlusCustomerContactResource: GetAllAsync, GetAsync, CreateAsync with CustomerContactCreateRequest, UpdateAsync with the sparse CustomerContactUpdateRequest, DeleteAsync) over /api/v1/Customer/{customer}/Contact, validated before sending. Breaking: Receivers and Memos are removed from CustomerCreateRequest and CustomerUpdateRequest because DK Plus ignores them in the customer document (a create with receivers and memos stored neither); item receivers are maintained through the new client.Customer.Receiver resource (IDkPlusCustomerReceiverResource: GetAllAsync, GetAsync, CreateAsync with CustomerReceiverCreateRequest, UpdateAsync with the sparse CustomerReceiverUpdateRequest, DeleteAsync) over /api/v1/Customer/{customer}/Reciver, validated before sending, plus GetByNumberAsync and GetCustomersAsync over /api/v1/Reciver/{number}[/customer]; CustomerReceiver gains Customer, the owning customer returned by the receiver endpoints; customer memos have no write endpoint and are read-only. Breaking: product create and update requests take dedicated nested request types (ProductBarcodeRequest, ProductWarehouseSettingsRequest with ProductVariationRequest, ProductUnitConversionRequest, ProductVendorRequest), and vendor and product Memos take MemoPageRequest, so product and memo writes never send stock quantities, purchase history or nested Modified times; a warehouse Variations list is omitted when null instead of always being sent as []. Warehouse.UpdateProductAsync now takes ProductWarehouseSettingsRequest instead of the ProductWarehouse read model and uses the same write mapper, so PUT productwarehouse/{warehouse}/product/{product} no longer sends QuantityInStock, QuantityOnBackOrders, QuantityPoOrders, Modified or an unset Variations list. GeneralLedger.Account.CreateAsync and GeneralLedger.TaxGroup.CreateAsync now take GeneralLedgerAccountCreateRequest and GeneralLedgerTaxGroupCreateRequest instead of the GeneralLedgerAccount and GeneralLedgerTaxGroup read models and never send Modified; the account Number is validated on the request before sending. The unused ProductWarehouse and WarehouseProductVariation read models are removed. Modified is no longer filled from DK Plus ObjectDate, so Modified can now be null; ObjectDate is exposed as a separate read-only member on customer, customer transaction, vendor, product, sales invoice, order, quote, recurring sale, v2 sales line, work request and project journal (CustomerTransaction.ObjectDate is new); it is projectable with Include on customer, customer transaction, vendor, product, sales invoice, order, quote and recurring-sale queries and on work-request pages through a raw Include list, but not on v2 lines or project journals. ObjectDate is the dkPlus stored time (display label Stored Date / Vistað dags.) and Modified is the dkERP modification time; for entities whose DK Plus list response model has ObjectDate, DK Plus pages and applies modified-since, modified-after and modified-before windows on ObjectDate (otherwise on Modified), and the client only forwards the window. The XML docs of the date members, queries and filters say so. Numeric vendor transaction enum fields (all but JournalType) read as their Swagger names instead of digits. Unknown members of nested vendor approvers, dimensions, expense defaults, delivery settings, bank accounts and project phase links are kept in their own AdditionalData and written back on update. A work-request page with a raw Include list always selects both Id and Number, so every returned record can be updated or deleted. Sales quote lines (head Lines, CreateLineAsync and UpdateLineAsync) that set QuantityDelivered, OrderedQuantity, DailyRate or IndexRatio are rejected before sending, because v2 quote lines cannot store them. Work-entry updates no longer send an empty Properties object. Queries transparently continue past the DK Plus limit of 50,000 records per query: ToListAsync, AsAsyncEnumerable, ToPageAsync and the dimension, project phase, task and journal list methods restart at page 1 with modified-after set one millisecond before the newest paging timestamp (never earlier than the caller's ModifiedSince) and skip records already returned at that boundary. The paging field is fixed per entity, not per record: ObjectDate for customers, products, vendors, sales invoices, customer transactions, v2 orders, quotes, recurring sales and project journals (their DK Plus list responses have it), Modified for projects, dimensions, project phases and tasks. A query that cannot continue safely throws InvalidOperationException asking for a narrower ModifiedSince/ModifiedBefore window: no modification filter, records out of order, a full window within one millisecond, a new window that would not start after the previous one, DK Plus serving a record before the window start (it ignored the filter), or a boundary record without identity or more than 2,000 records in the boundary millisecond. Direct page methods reject page × count above 50,000 with ArgumentOutOfRangeException before sending. ToPageAsync continuation tokens are opaque (version 3): they store boundary identities only as truncated SHA-256 hashes plus timestamps and a hash of the query's filters, should not be logged, and a token from an earlier version, another record type or a query with other filters is rejected with ArgumentException. A fluent Include projection on a paged query always selects the identity and the paging field right after it (customer transactions now force Id, wire ID, and page on ObjectDate instead of Modified), and an Exclude that names either throws ArgumentException before sending, so a projected query still continues past the limit; existing include lists therefore gain ObjectDate, for example the ERPC-21 customer projection now sends include=Number,ObjectDate,VATNumber,ISATNumber,Gender,NoVat,LedgerCode, and a customer transaction Include(Text) sends include=ID,ObjectDate,Text. v2 order and recurring-sale creates with lines now work, and quote creates with lines are limited by a known DK Plus issue (below): DK Plus rejects lines in the create body, so CreateAsync posts the head without Lines, then each line to {uid}/Line in order, then re-reads the document so the result carries its lines; the whole request is validated before the first request. Once every line is stored, a failed or canceled re-read no longer fails the create: a warning is logged and the head response is returned, whose lines may be missing. If a line fails or the call is canceled, the new head is deleted and the original exception is rethrown; if that delete also fails, an AggregateException carries both and the document may remain. When the request supplies the document Number, the head is never deleted (DK Plus may have matched an existing document), and the exception's operation and message name the UId and say the head was not deleted. Known DK Plus issue: DK Plus can create a v2 quote head with Id 0 and no Number, after which its line POST fails (Assigning Null to headid!) and its delete answers Record not found!; Sales.Quote.CreateAsync with lines then sends no line and no delete and throws InvalidOperationException naming the UId and stating that the lines were not added and the head may not be deletable. v2 order, quote and recurring-sale updates with lines now apply the lines: DK Plus ignores Lines on the head PUT, so UpdateAsync sends the head without Lines, then each request line in order, PUT {uid}/Line/{sequence} for a line with a Sequence and POST {uid}/Line for a line without one; stored lines that are not listed are kept, an update that sets only Lines sends no head PUT, and Lines = [] no longer sends an empty Lines member. The head and every line are validated before the first request, and a null line is rejected as Lines[i]. Nothing is rolled back: when a line fails, the exception keeps its status, response body and correlation ID, and its operation and message name the failed line and state that the head and the earlier lines were already applied. Sales.Quote.DeleteAsync, and the compensating delete of a failed quote create, use the Swagger route DELETE /api/v2/sales/quote/api/v2/sales/quote/{uid}, because DELETE /api/v2/sales/quote/{uid} answers HTTP 405 (the new route is not yet confirmed live). The XML docs of SalesDocumentLineRequest.QuantityDelivered and OrderedQuantity state that DK Plus stores the line Quantity in both on order lines. Customer.Contact.UpdateAsync and Customer.Receiver.UpdateAsync reject a request that sets no member with ArgumentException before sending; the contact docs note that get and update by contact number did not behave as expected in a live run. v0.3.1: fixes reads that failed on 0.3.0 because DK Plus returns enum fields as numbers (customer, product, employee, product category, vendor transaction and v2 project journal reads); numeric enum values now map to their members, and an unrecognized value reads as null with the raw value kept in AdditionalData. Text enum fields read a number as its Swagger spelling, so portable Project.Status and v2 order and quote Status read InProgress instead of digits (likewise employee Status, token Type, request-log ServiceType, customer transaction RecordType, product category Type, project transaction fields, v2 Origin and project journal Status); a numeric vendor-invoice ApprovedStatus now maps to its member, and an unknown company or permission enum value reads as null instead of failing the read. v0.3.0 (breaking): every IDkPlusClient read member returns a sealed DkPlus subtype of the portable model (DkPlusCustomer, DkPlusVendor, DkPlusProduct, DkPlusProject, DkPlusEmployee, DkPlusSalesInvoice, DkPlusSalesOrder, DkPlusSalesQuote, DkPlusRecurringSale, DkPlusSalesPerson), while IExternalErpClient keeps returning the portable types. ERPC-21: VatNumber, IndustryClassificationCode, Gender, VatExempt and ReceivablesAccount are DkPlusCustomer members and are projectable with Include. Writes use one pattern per entity: a CreateRequest for the full document and a sparse UpdateRequest whose identity is the path argument; null means unchanged and an empty string is sent as written. The overloads that accepted read models, CustomerCreateRequest wrapper, Vendor.NoVat alias and SalesPersonDetails are removed. Renames include InvoiceDraft to SalesInvoiceCreateRequest, InvoiceLine to SalesInvoiceLineRequest, VendorInvoiceDraft to VendorInvoiceCreateRequest, ProjectRequest to WorkRequest (Project.WorkRequest), VendorBankAccount to BankAccount and CustomerOptions to flat DkPlusCustomer members. Swagger-enumerated fields are C# enums; unknown wire values read as null and are kept in AdditionalData. Request extension data never overrides a typed member. v1 invoice and v2 document parties reject members DK Plus cannot store before any request is sent. v2 sales documents map the contact Address, keep unknown nested party, contact, carrier and extra-details members in AdditionalData (and unknown Dimensions and Payment members in the parent's AdditionalData), type Receiver as DkPlusSalesDocumentParty, and accept DiscountAmount and a line Sequence; UpdateLineAsync copies the path sequence into the body (matching by Sequence on a head PUT is unverified live). A contact City or fourth address line, and party or contact data a v1 invoice cannot store, are rejected before sending. Raw include lists (vendor page, project and work-request lists, ledger accounts) always select the identity and exclude lists may not drop it; a read without its identity raises DkPlusContractException. Work-request updates copy the path id into the body, and non-string vendor-invoice approval status tokens read as null with the raw value retained. Vendor invoice attachments use FileAttachment and a missing name or content raises DkPlusContractException. Privacy: the employee payroll fields SpouseName, BankAccount, SalaryCode, PaymentFrequency and PaymentMode are stripped from AdditionalData on every employee read and are never written; an employee projection may Exclude them but not Include them. Employee, v1 sales invoice (Number) and v2 sales document (Id/UId) projections always include the identity and refuse to exclude it. DkPlusGender is renamed Gender and TokenUpdate is renamed TokenUpdateRequest. v2 order and quote Status (SalesDocumentStatus), order DeliveryStatus (SalesOrderDeliveryStatus, also on DkPlusSalesOrder) and project Status (ProjectStatus) are enums validated before sending. Salesperson, v2 document and project-phase create requests enforce their [Required] members before any I/O, and work-entry writes no longer send JSON null extension values. Retains the portable-read and DK-specific separation, DkPlus projections, correlation IDs and resilience behavior of earlier releases. v0.4.2: an invoice create or price-calculation request whose Customer is null (for example read from JSON) now fails with DkPlusModelValidationException before sending instead of a NullReferenceException.