NikSBO 0.4.0

dotnet add package NikSBO --version 0.4.0
                    
NuGet\Install-Package NikSBO -Version 0.4.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="NikSBO" Version="0.4.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="NikSBO" Version="0.4.0" />
                    
Directory.Packages.props
<PackageReference Include="NikSBO" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add NikSBO --version 0.4.0
                    
#r "nuget: NikSBO, 0.4.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package NikSBO@0.4.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=NikSBO&version=0.4.0
                    
Install as a Cake Addin
#tool nuget:?package=NikSBO&version=0.4.0
                    
Install as a Cake Tool

NikSBO

A C# SDK for the SAP Business One Service Layer, multi-targeted for .NET 8 / .NET 6 / .NET Standard 2.0 (so it also runs on .NET Framework 4.6.2+ — handy for SAP B1 add-ons). Built so you don't have to fight SAP's OData API by hand: login with auto-renewal, typed CRUD, a fluent query builder (with LINQ), document actions (Close, Cancel, …), transactional batches, parameterized raw SQL, UDF support, IAsyncDisposable (auto-logout via await using), a logging hook and CancellationToken on every async method.


Requirements

  • .NET 8, .NET 6, or any platform compatible with .NET Standard 2.0 (this includes .NET Framework 4.6.2+, Mono 5.4+, Unity, Xamarin, etc.)
  • Access to a SAP B1 Service Layer (HANA or SQL Server) — typically exposed at https://<host>:50000/
  • A SAP user with permissions on the target company database

Installation

Install from NuGet:

dotnet add package NikSBO

Or pin a specific version:

dotnet add package NikSBO --version 0.1.0

To work from source instead, clone the repo and open NikSBO.slnx, or reference the project directly:

dotnet add reference ../NikSBO/NikSBO.csproj

Configuration

The connection is fully described by B1Options:

using NikSBO.http;
using NikSBO.models;

var client = new B1Client(new B1Options
{
    ServerUrl = "https://sap.mycompany.local:50000/",
    CompanyDb = "SBODemoUS",
    Username  = "manager",
    Password  = "********"
});

To avoid hardcoding credentials, use appsettings.json:

{
  "B1": {
    "ServerUrl": "https://sap.mycompany.local:50000/",
    "CompanyDb": "SBODemoUS",
    "Username":  "manager",
    "Password":  "********"
  }
}
var config = JsonSerializer.Deserialize<JsonElement>(File.ReadAllText("appsettings.json"));
var b1 = config.GetProperty("B1");
var client = new B1Client(new B1Options
{
    ServerUrl = b1.GetProperty("ServerUrl").GetString()!,
    CompanyDb = b1.GetProperty("CompanyDb").GetString()!,
    Username  = b1.GetProperty("Username").GetString()!,
    Password  = b1.GetProperty("Password").GetString()!
});

TLS certificate validation

By default the client validates the Service Layer's TLS certificate against the standard chain of trust. SAP B1 on-prem usually ships a self-signed certificate, so you'll get connection errors unless you either:

  • Install the SL certificate in the machine's trust store, or
  • Opt into accepting any certificate via B1Options.AcceptAnyServerCertificate:
var client = new B1Client(new B1Options
{
    ServerUrl = "https://sap.mycompany.local:50000/",
    CompanyDb = "SBODemoUS",
    Username  = "manager",
    Password  = "********",
    AcceptAnyServerCertificate = true   // self-signed cert on internal network
});

Only enable this flag over a trusted network (LAN, VPN). Over Internet, public WiFi or unsegmented networks it opens the door to MITM attacks: an attacker can present their own certificate, read credentials and tamper with data in transit.


Login / Logout

await client.Login();   // stores the B1SESSION cookie
// ... operations ...
await client.Logout();

Service Layer sessions expire after 30 minutes. B1Client tracks when the login happened and renews the session automatically:

  • Before every request, if IsExpired(), it re-logs in.
  • If the SL still answers 401 Unauthorized, the client re-logs in transparently and retries the request once.

If you ever need to check it yourself:

if (client.IsExpired(TimeSpan.FromMinutes(1)))
    await client.Login();

DateTimeOffset? expiresAt = client.ExpiresAt;

Logging

The SDK doesn't ship a logger — instead, set B1Options.LogTrace to a delegate that receives a pre-formatted string per event. No external dependency, no log levels, no structured logging: just a hook.

var client = new B1Client(new B1Options
{
    ServerUrl = "...",
    Username = "...",
    Password = "...",
    LogTrace = msg => Console.WriteLine($"[NikSBO] {msg}")
});

What you'll see in the console:

[NikSBO] Login -> https://sap:50000/b1s/v1/Login (user: manager, db: SBODemoUS)
[NikSBO] Login OK (234 ms, sesión hasta 2026-05-20 13:42:10Z)
[NikSBO] GET /b1s/v1/BusinessPartners?$filter=... -> 200 OK (87 ms)
[NikSBO] Sesión caducada, re-login automático
[NikSBO] Login -> https://sap:50000/b1s/v1/Login (user: manager, db: SBODemoUS)
[NikSBO] Login OK (198 ms, ...)
[NikSBO] POST /b1s/v1/Orders(123)/Close -> 204 No Content (156 ms)
[NikSBO] Auto-logout via DisposeAsync
[NikSBO] Logout -> https://sap:50000/b1s/v1/Logout
[NikSBO] Logout OK (89 ms)

Common adaptations

To a file:

LogTrace = msg => File.AppendAllText("niksbo.log",
    $"{DateTime.Now:O} {msg}{Environment.NewLine}")

Bridge to ILogger (ASP.NET Core / Microsoft.Extensions.Logging):

LogTrace = msg => _logger.LogDebug(msg)

Add timestamps and PID:

LogTrace = msg => Console.WriteLine(
    $"{DateTime.UtcNow:HH:mm:ss.fff} [pid {Environment.ProcessId}] {msg}")

If LogTrace is null (the default), nothing is emitted and there's no overhead beyond a Stopwatch per request (negligible).


Disposing the client

B1Client implements IAsyncDisposable, so you can let await using handle the Logout for you:

await using var client = new B1Client(new B1Options { ... });
await client.Login();

// ... your operations ...

// On scope exit: Logout against the SL + HttpClient cleanup, automatically.

If the Logout fails during disposal (server down, network blip, …), the exception is swallowed silently — Dispose must never throw. If you need to know whether the Logout succeeded, call await client.Logout() explicitly before exiting the scope.

Only IAsyncDisposable is implemented (not synchronous IDisposable). Synchronous dispose would have to block on Logout, which can deadlock in UI/ASP.NET sync contexts. If you can't use await using (legacy sync code on .NET Framework), call await client.Logout() manually.


Cancellation

Every async method (Login, Logout, GetAsync, PostAsync, Query<T>.GetAsync, B1Batch.SubmitAsync, SqlAsync, …) accepts an optional CancellationToken as the last parameter:

using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(5));   // hard timeout

try
{
    var customers = await client
        .Query<BusinessPartner>()
        .Where(bp => bp.CardType == "cCustomer")
        .GetAsync(cts.Token);
}
catch (OperationCanceledException)
{
    // cancelled by timeout or by user
}

In ASP.NET Core, propagate the request's token so a disconnected client aborts the SAP call instead of leaving a thread waiting:

public async Task<IActionResult> Get(CancellationToken ct)
{
    var bps = await _client.Query<BusinessPartner>().GetAsync(ct);
    return Ok(bps);
}

GetAllAsync also checks the token between pages, so a long pagination walk stops promptly.

For ExecuteAsync (raw HTTP escape hatch), the delegate now receives the token so you can pass it to your own call:

var response = await client.ExecuteAsync(
    (http, ct) => http.GetAsync("b1s/v1/SomeEndpoint", ct),
    cancellationToken: ct);

CRUD

Two flavors: manual endpoint (handy for UDOs or unmodeled resources) and typed model (the endpoint is resolved from the [B1Entity] attribute).

With a typed model

// GET by key
var customer = await client.GetAsync<BusinessPartner>("C30000");
var order    = await client.GetAsync<SalesOrder>(123);     // numeric key

// POST (create)
var created = await client.PostAsync<BusinessPartner>(new BusinessPartner
{
    CardCode = "C99999",
    CardName = "Test Customer",
    CardType = "cCustomer"
});

// PATCH (update)
await client.PatchAsync<BusinessPartner>("C99999", new
{
    EmailAddress = "new@email.com"
});

// DELETE
await client.DeleteAsync<BusinessPartner>("C99999");

With a manual endpoint

Useful for UDOs (user-defined objects) or any resource you don't have a model for:

// GET against a UDO
var route = await client.GetByEndpointAsync<JsonElement>("MY_UDO('R001')");

// POST
await client.PostByEndpointAsync<JsonElement>("MY_UDO", new
{
    Code = "R002",
    Name = "North Route"
});

// PATCH
await client.PatchByEndpointAsync("MY_UDO('R002')", new { Name = "North Route (edited)" });

// DELETE
await client.DeleteByEndpointAsync("MY_UDO('R002')");

The b1s/v1/ prefix is added automatically: "BusinessPartners" and "b1s/v1/BusinessPartners" both work.


Querying (query builder)

Chain filters and projections, then execute with GetAsync(), GetAllAsync() or CountAsync().

Against a model

var activeCustomers = await client
    .Query<BusinessPartner>()
    .Where(bp => bp.CardType == "cCustomer")
    .OrderBy("CardCode")
    .Top(50)
    .GetAsync();

Against an endpoint (UDOs or unmodeled resources)

var routes = await client
    .Query<JsonElement>("MY_UDO")
    .Where("Code", "R001")
    .GetAsync();

Filters: two styles

Typed LINQ (recommended). Translates Expression<Func<T, bool>> to an OData $filter, escaping quotes and formatting dates/decimals under invariant culture:

var today = DateTime.Today;

var recentOrders = await client.Query<SalesOrder>()
    .Where(o => o.CardCode == "C30000" && o.DocDate >= today.AddDays(-30))
    .GetAllAsync();

// Supports:
//   ==, !=, <, >, <=, >=
//   &&, ||, !
//   string.Contains / StartsWith / EndsWith
//   captured variables (closures)
var name = "Demo";
var hits = await client.Query<BusinessPartner>()
    .Where(bp => bp.CardName.Contains(name))
    .GetAsync();

String-based (field / operator / value). Handy when the field name is dynamic, or when filtering by a UDF:

using NikSBO.Enums;

var bps = await client.Query<BusinessPartner>()
    .Where("CardType", BOCondition.Equals, "cCustomer")
    .Where("U_MY_FIELD", BOCondition.Equals, "R001") // UDF
    .GetAsync();

Available operators (BOCondition): Equals, NotEquals, GreaterThan, LessThan, GreaterOrEqual, LessOrEqual. Shortcut: Where("Field", "value") is equivalent to Equals. Multiple Where calls are combined with and.

Select, Top, OrderBy

Select supports three styles. Mix and match as you prefer:

// 1) Strings — handy for dynamic field names, UDFs, or unmodeled endpoints
var a = await client.Query<Item>()
    .Select("ItemCode", "ItemName", "PriceList")
    .Top(100).GetAsync();

// 2) Individual lambdas
var b = await client.Query<Item>()
    .Select(i => i.ItemCode, i => i.ItemName)
    .Top(100).GetAsync();

// 3) Anonymous type — most idiomatic when projecting several fields
var c = await client.Query<Item>()
    .Select(i => new { i.ItemCode, i.ItemName, i.PriceList })
    .OrderByDesc("ItemCode")
    .Top(100).GetAsync();

The lambda variants give you compile-time safety: rename a property in the model and the call won't compile. The string variant only fails at runtime against SAP.

Tuple literals (i => (i.A, i.B)) are not valid here — expression trees in C# don't support them (CS8143). Use the anonymous type form instead.

Automatic pagination

By default the Service Layer returns 20 rows plus an odata.nextLink. GetAllAsync() walks every page and concatenates the results:

var all = await client.Query<Item>()
    .Where(it => it.Valid == "tYES")
    .GetAllAsync();

For large collections (tens of thousands of rows), GetAllAsync() loads everything into memory — check the size before reaching for it.

Count

int total = await client.Query<BusinessPartner>()
    .Where(bp => bp.CardType == "cCustomer")
    .CountAsync();

Document actions (Close, Cancel, …)

SAP B1 exposes operations like closing or cancelling a document as actions — POST /Endpoint(key)/ActionName with no body. These aren't CRUD, they change the document's state.

Generic — works with any action

// Close a sales order
await client.InvokeActionAsync<SalesOrder>(123, "Close");

// Cancel an invoice
await client.InvokeActionAsync<Invoice>(456, "Cancel");

// Any future SAP action without SDK updates
await client.InvokeActionAsync<DeliveryNote>(789, "MarkAsClosed");

For documents with string keys, or for UDOs:

// String key
await client.InvokeActionAsync<MyDoc>("ABC", "Close");

// Manual endpoint (UDO with custom action)
await client.InvokeActionByEndpointAsync("MY_UDO('R001')", "MyCustomAction");

Typed shortcut for Close

Close is the most common action, so there's a typed shortcut:

await client.CloseAsync<SalesOrder>(123);
await client.CloseAsync<Quotation>(456);

Internally it's just InvokeActionAsync<T>(key, "Close") — same call, less typing.

Actions that return a payload (rare) aren't supported by these helpers yet. For those, use ExecuteAsync and read the response manually.


Batch (atomic transactions)

The Service Layer batch endpoint groups operations into an atomic changeset: if any of them fails, the rest are rolled back.

var batch = client.CreateBatch();

batch.Post("BusinessPartners", new BusinessPartner { CardCode = "C00001", CardName = "One" });
batch.Post("BusinessPartners", new BusinessPartner { CardCode = "C00002", CardName = "Two" });
batch.Patch("BusinessPartners('C30000')", new { CardName = "Renamed" });
batch.Delete("BusinessPartners('C99999')");

var results = await batch.SubmitAsync();

for (int i = 0; i < results.Count; i++)
{
    var r = results[i];
    if (r.IsSuccess)
        Console.WriteLine($"Op {i}: OK ({r.StatusCode}) -> {r.Body}");
    else
        Console.WriteLine($"Op {i}: FAILED ({r.StatusCode}) -> {r.Body}");
}

Each BatchResult carries:

  • StatusCode — the HTTP code SAP returned for that operation
  • Body — the raw JSON body (the created entity with its DocEntry, or the SAP error)
  • IsSuccess — shortcut for 200 <= StatusCode < 300

If the outer envelope itself fails (auth expired, server down, etc.) a B1Exception is thrown — there's no per-operation result in that case.


Raw SQL (SqlAsync)

SAP doesn't expose SqlQueries results over a direct GET, so the SDK creates, executes and deletes the query behind the scenes (three round trips).

var result = await client.SqlAsync(@"
    SELECT TOP 10 ""CardCode"", ""CardName""
    FROM OCRD
    WHERE ""CardType"" = 'C'
");

Parameters (avoid SQL injection)

When values come from user input, never concatenate them into the SQL string. Use named parameters with :name placeholders and pass the values as an anonymous type (or a dictionary):

var rows = await client.SqlAsync(
    @"SELECT ""CardCode"", ""CardName""
      FROM OCRD
      WHERE ""CardType"" = :type
        AND ""CardCode"" LIKE :prefix",
    new { type = "C", prefix = userInput + "%" });

The SDK formats each value safely:

C# type URL formatting
string 'value' with ' doubled (O'Brien → 'O''Brien')
int, long, … 42 (no quotes)
bool true / false
decimal, double, float 12.5 (invariant culture, dot)
DateTime '2024-01-15T10:30:00' (ISO 8601)
null null

For dynamic parameter sets, use a dictionary:

var filter = new Dictionary<string, object>
{
    ["type"] = "C",
    ["minBalance"] = 1000m
};

var rows = await client.SqlAsync(
    @"SELECT * FROM OCRD WHERE ""CardType"" = :type AND ""Balance"" >= :minBalance",
    filter);

If the SQL fails to execute, the SQLQueries entry that the SDK created is still cleaned up afterwards — no orphan queries left in SAP.

Result type is still object (raw JSON). Typed deserialization would be a separate feature.


Schema / Metadata (UDTs, UDFs, UDOs)

Create SAP B1 user-defined tables, fields and objects programmatically. Ideal for add-on installers that need to ensure their schema exists when the add-on starts.

All three methods are idempotent: they check whether the entity already exists and only POST if it doesn't. Run them on every startup; nothing happens after the first time.

using NikSBO.Metadata;
using NikSBO.Enums;

// 1. A User-Defined Table (UDT)
await client.EnsureUserTableAsync(new UserTableDefinition
{
    TableName        = "ROUTES",
    TableDescription = "Delivery routes",
    TableType        = UserTableType.MasterData    // see "Type coherence" below
});

// 2. A User-Defined Field (UDF) on the UDT (or on any SAP standard table)
await client.EnsureUserFieldAsync(new UserFieldDefinition
{
    TableName   = "@ROUTES",                      // for UDTs include the "@"
    Name        = "DESCRIPTION",                  // the SDK adds the "U_" automatically
    Description = "Route description",
    Type        = UserFieldType.Alphanumeric,
    Size        = 100,
    Mandatory   = true
});

// UDFs with a combo (valid values)
await client.EnsureUserFieldAsync(new UserFieldDefinition
{
    TableName   = "OCRD",                          // SAP standard table
    Name        = "STATUS",
    Description = "Customer status",
    Type        = UserFieldType.Alphanumeric,
    Size        = 1,
    ValidValues = new List<ValidValue>
    {
        new("A", "Active"),
        new("I", "Inactive"),
        new("P", "Pending")
    }
});

// 3. A User-Defined Object (UDO) on top of the UDT
await client.EnsureUserObjectAsync(new UserObjectDefinition
{
    Code                 = "ROUTE_UDO",
    Name                 = "Routes",
    TableName            = "ROUTES",                // UDT base, no "@"
    ObjectType           = UserObjectType.MasterData,
    CanCreateDefaultForm = true,
    CanFind              = true,
    CanDelete            = true
});

What gets logged when LogTrace is active:

[NikSBO] GET /b1s/v1/UserTablesMD('ROUTES') -> 404 Not Found (12 ms)
[NikSBO] Creando UserTable 'ROUTES'...
[NikSBO] POST /b1s/v1/UserTablesMD -> 201 Created (340 ms)
[NikSBO] UserTable 'ROUTES' creada

A second run of the same code:

[NikSBO] GET /b1s/v1/UserTablesMD('ROUTES') -> 200 OK (8 ms)
[NikSBO] UserTable 'ROUTES' ya existe, skip

Type coherence between UDT and UDO

When you put a UDO on top of a UDT, their types must match:

UDT type Compatible UDO type
UserTableType.MasterData UserObjectType.MasterData
UserTableType.Document UserObjectType.Document
UserTableType.NoObject None — no UDO on top of these

A UDT of type NoObject is a standalone user table without any UDO. If you try to create a UDO on top of a NoObject UDT, SAP returns Table '<name>' of type Master Data does not exist.

Caveats

  • Permissions: the SAP user needs Superuser or equivalent metadata-management permissions.
  • Service Layer cache: after creating a UDF, the SL might still not "see" the new column for some seconds. SAP recommends a quick re-login if you need to immediately query that field. The SDK can't force this.
  • Partial rollback on failure: if you create UDT + 5 UDFs + UDO and the UDO POST fails, the previous entities stay in SAP. The Ensure* methods are designed to be re-runnable, so the next invocation completes what's left.
  • Boolean legacy serialization: UserObjectsMD is an old endpoint that uses SAP's internal BoYesNoEnum ("tYES"/"tNO") for booleans instead of JSON true/false. The SDK handles this transparently — you keep using bool? on the DTO.
  • Null properties are omitted: thanks to JsonIgnoreCondition.WhenWritingNull, only the properties you explicitly set are sent to SAP. This avoids version-specific issues where some SAP versions don't recognize certain property names.

Property naming conventions

  • UDTs: stored in DB with @ prefix; in the SDK pass the name without the @ ("ROUTES").
  • UDFs: stored in DB with U_ prefix; in the SDK pass the name without the U_ ("DESCRIPTION").
  • UDFs on a UDT: when filtering or referencing the parent table, use the @ form ("@ROUTES").
  • UDOs on a UDT: when setting TableName, use the bare form (no @).

Bundled models

Under NikSBO.models:

  • BusinessPartner → BusinessPartners
  • Item → Items
  • ContactEmployee → ContactEmployees
  • SalesPerson → SalesPersons
  • BusinessPlace → BusinessPlaces
  • Warehouse → Warehouses
  • ItemGroup → ItemGroups
  • VatGroup → VatGroups
  • Marketing documents (subclasses of MarketingDocument):
    • Sales: SalesOrder (Orders), Invoice, Quotation, DeliveryNote, CreditNote, Return, DownPaymentRequest, Draft, DownPayment
    • Purchasing: PurchaseOrder, PurchaseInvoice, PurchaseRequest, PurchaseDeliveryNote, PurchaseReturn, PurchaseCreditNote, PurchaseDownPayment
  • DocumentLine for document lines

All of them inherit from B1Model, so they support UDFs.

UDFs (user-defined fields)

Any JSON property that isn't bound to a strong-typed property falls into ExtensionData automatically:

var bp = await client.GetAsync<BusinessPartner>("C30000");
string?  route   = bp.GetUDF<string>("U_MY_FIELD");
decimal? balance = bp.GetUDF<decimal>("U_AVAILABLE_BALANCE");

To write UDFs in POST/PATCH, just include them in the body (an anonymous type works fine):

await client.PatchAsync<BusinessPartner>("C30000", new
{
    CardName   = "Demo Customer",
    U_MY_FIELD = "R002"
});

Defining your own models

Annotate with [B1Entity("ServiceLayerEndpoint")] and inherit from B1Model if you want UDF support:

using NikSBO.models;

[B1Entity("MY_UDO")]
public class Route : B1Model
{
    public string Code { get; set; } = "";
    public string Name { get; set; } = "";
}

// then use Query<Route>(), GetAsync<Route>(...), etc.
var routes = await client.Query<Route>().Where(r => r.Code == "R001").GetAsync();

Error handling

Every call throws B1Exception when the Service Layer answers with a non-success status:

using NikSBO.Exceptions;

try
{
    await client.PostAsync<BusinessPartner>(new BusinessPartner { CardCode = "C30000" });
}
catch (B1Exception ex)
{
    Console.WriteLine($"HTTP: {(int)ex.HttpStatusCode}");
    Console.WriteLine($"SAP code: {ex.SapErrorCode}");
    Console.WriteLine($"Message: {ex.Message}");
    // or simply:
    Console.WriteLine(ex);
    // → SAP B1 Error [-2035] (HTTP 400): BusinessPartner with this code already exists
}

If the error body doesn't follow the standard OData shape, the exception still returns the raw body (SapErrorCode = 0) so no information is lost.


Full example

using NikSBO.Exceptions;
using NikSBO.http;
using NikSBO.models;

var client = new B1Client(new B1Options
{
    ServerUrl = "https://sap.mycompany.local:50000/",
    CompanyDb = "SBODemoUS",
    Username  = "manager",
    Password  = "******"
});

await client.Login();

try
{
    // Create a customer
    var customer = await client.PostAsync<BusinessPartner>(new BusinessPartner
    {
        CardCode = "C12345",
        CardName = "Demo Customer",
        CardType = "cCustomer"
    });

    // Create a sales order for that customer
    var order = await client.PostAsync<SalesOrder>(new SalesOrder
    {
        CardCode   = customer.CardCode,
        DocDate    = DateTime.Today,
        DocDueDate = DateTime.Today.AddDays(30),
        DocumentLines = new List<DocumentLine>
        {
            new() { ItemCode = "A00001", Quantity = 2, Price = 100 },
            new() { ItemCode = "A00002", Quantity = 1, Price = 250 }
        }
    });

    Console.WriteLine($"Order created: DocEntry={order.DocEntry}, DocNum={order.DocNum}");

    // List the customer's open orders
    var open = await client.Query<SalesOrder>()
        .Where(o => o.CardCode == customer.CardCode && o.DocumentStatus == "bost_Open")
        .OrderByDesc("DocDate")
        .GetAllAsync();

    Console.WriteLine($"{open.Count} open orders for {customer.CardCode}");
}
catch (B1Exception ex)
{
    Console.WriteLine($"Failed: {ex}");
}
finally
{
    await client.Logout();
}

Running the tests

The repo ships a NikSBO.Tests project (xUnit) covering the OData expression translator and the query builder's URL generation. They run offline in milliseconds — no Service Layer needed:

dotnet test

In Visual Studio, open NikSBO.slnx and run Test → Test Explorer.


Known limitations

  • Amounts on marketing documents are double, not decimal — watch out for precision loss
  • The query builder doesn't have Skip / $expand / $search
  • SqlAsync returns object (raw JSON), not typed List<T>
  • Document actions that return a payload aren't covered by the typed helpers — use ExecuteAsync for those
  • No IServiceCollection integration for DI (intentional — target audience is SAP B1 add-on development on .NET Framework where DI isn't the norm)

License

TBD.

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 is compatible.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  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. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.4.0 137 5/26/2026
0.3.0 117 5/20/2026
0.2.0 117 5/20/2026
0.1.0 128 5/16/2026

0.4.0 — Módulo de metadata para crear UDTs, UDFs y UDOs programáticamente.

Nuevas features:
- client.EnsureUserTableAsync(UserTableDefinition) — POST /UserTablesMD idempotente.
- client.EnsureUserFieldAsync(UserFieldDefinition) — POST /UserFieldsMD con lookup por $filter (UDFs no tienen single-key).
- client.EnsureUserObjectAsync(UserObjectDefinition) — POST /UserObjectsMD idempotente.
- Cuatro enums tipados (UserTableType, UserFieldType, UserFieldSubType, UserObjectType) decorados con [EnumMember] y serializados vía SapEnumConverter<T> a los strings internos del SL (bott_, db_, st_, boud_).
- ValidValue record para UDFs con listas combo (ValidValuesMD).
- BoolToYesNoConverter transparente para UserObjectsMD, que usa BoYesNoEnum (tYES/tNO) en vez de JSON true/false.

Pensado para installers de addons: ejecutar Ensure* en cada arranque garantiza el schema sin afectar instalaciones ya hechas.

Sin breaking changes desde 0.3.0. Tests: 37 (antes 32).