diff --git a/.gitignore b/.gitignore
index 4ce6fdd..5ede8ab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -337,4 +337,8 @@ ASALocalRun/
.localhistory/
# BeatPulse healthcheck temp database
-healthchecksdb
\ No newline at end of file
+healthchecksdb
+
+# Migration test client local credentials (public repo — never commit real creds)
+Bricknode.MigrationTestClient/appsettings.json
+Bricknode.MigrationTestClient/appsettings.local.json
\ No newline at end of file
diff --git a/Bricknode.MigrationTestClient/AppConfig.cs b/Bricknode.MigrationTestClient/AppConfig.cs
new file mode 100644
index 0000000..1706fc0
--- /dev/null
+++ b/Bricknode.MigrationTestClient/AppConfig.cs
@@ -0,0 +1,23 @@
+namespace Bricknode.MigrationTestClient;
+
+///
+/// Credentials + endpoints for the demo run. Bound from appsettings.json / appsettings.local.json
+/// / environment variables (prefix BFS_). Never commit real values — see appsettings.example.json.
+///
+internal sealed class AppConfig
+{
+ public string Username { get; set; } = "";
+ public string Password { get; set; } = "";
+ public string Identifier { get; set; } = "";
+
+ /// SOAP endpoint, e.g. https://your-instance.bricknode.com/api/BFSApi.asmx
+ public string SoapEndpoint { get; set; } = "";
+
+ /// REST API base address, e.g. https://your-instance.bricknode.com/
+ public string RestEndpoint { get; set; } = "";
+
+ public bool HasCredentials =>
+ !string.IsNullOrWhiteSpace(Username) &&
+ !string.IsNullOrWhiteSpace(Password) &&
+ !string.IsNullOrWhiteSpace(Identifier);
+}
diff --git a/Bricknode.MigrationTestClient/Bricknode.MigrationTestClient.csproj b/Bricknode.MigrationTestClient/Bricknode.MigrationTestClient.csproj
new file mode 100644
index 0000000..526b8b5
--- /dev/null
+++ b/Bricknode.MigrationTestClient/Bricknode.MigrationTestClient.csproj
@@ -0,0 +1,46 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ latest
+ Bricknode.MigrationTestClient
+ $(DefineConstants);USE_REST
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Bricknode.MigrationTestClient/Program.cs b/Bricknode.MigrationTestClient/Program.cs
new file mode 100644
index 0000000..a427307
--- /dev/null
+++ b/Bricknode.MigrationTestClient/Program.cs
@@ -0,0 +1,75 @@
+using BfsApi;
+using Bricknode.MigrationTestClient;
+using Bricknode.Soap.Sdk.Extensions;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+// Local verification tool + migration example.
+// dotnet run -> original SOAP SDK
+// dotnet run -p:UseRest=true -> REST drop-in (Bricknode.Rest.CompatSdk)
+// All code below is ordinary SDK consumer code; only the package reference changes.
+
+
+var config = LoadConfig();
+if (!config.HasCredentials)
+{
+ Console.Error.WriteLine(
+ "No credentials found. Copy appsettings.example.json to appsettings.json and fill it in, " +
+ "or set BFS_Username / BFS_Password / BFS_Identifier environment variables.");
+ return 1;
+}
+
+#if USE_REST
+const string sdk = "REST (Bricknode.Rest.CompatSdk)";
+var endpoint = config.RestEndpoint;
+#else
+const string sdk = "SOAP (Bricknode.Soap.Sdk)";
+var endpoint = config.SoapEndpoint;
+#endif
+
+if (string.IsNullOrWhiteSpace(endpoint))
+{
+ Console.Error.WriteLine($"No endpoint configured for {sdk}.");
+ return 1;
+}
+
+Console.WriteLine($"Bricknode migration test client — {sdk}");
+Console.WriteLine($"Endpoint: {endpoint}");
+
+var services = new ServiceCollection();
+services.AddLogging(builder => builder.AddSimpleConsole(o => o.SingleLine = true));
+services.AddBfsApiClient(cfg =>
+{
+ cfg.Credentials = new Credentials { UserName = config.Username, Password = config.Password };
+ cfg.Identifier = config.Identifier;
+ cfg.EndpointAddress = endpoint;
+});
+services.AddTransient();
+
+await using var provider = services.BuildServiceProvider();
+
+try
+{
+ await provider.GetRequiredService().RunAsync();
+ return 0;
+}
+catch (Exception ex)
+{
+ Console.Error.WriteLine($"Failed: {ex.GetType().Name}: {ex.Message}");
+ for (var inner = ex.InnerException; inner is not null; inner = inner.InnerException)
+ Console.Error.WriteLine($" inner: {inner.GetType().Name}: {inner.Message}");
+ return 1;
+}
+
+static AppConfig LoadConfig()
+{
+ var root = new ConfigurationBuilder()
+ .SetBasePath(Directory.GetCurrentDirectory())
+ .AddJsonFile("appsettings.json", optional: true)
+ .AddJsonFile("appsettings.local.json", optional: true)
+ .AddEnvironmentVariables("BFS_")
+ .Build();
+
+ return root.Get() ?? new AppConfig();
+}
diff --git a/Bricknode.MigrationTestClient/README.md b/Bricknode.MigrationTestClient/README.md
new file mode 100644
index 0000000..a77d750
--- /dev/null
+++ b/Bricknode.MigrationTestClient/README.md
@@ -0,0 +1,52 @@
+# Bricknode Migration Test Client
+
+A small console app for **local verification** of the SOAP → REST migration, written exactly the
+way an SDK consumer writes code: services (`IBfsAccountService`, `IBfsCurrencyService`) are
+constructor-injected via DI, DTOs come from `BfsApi`. The same code runs against either package —
+the only thing that changes is the package reference.
+
+## Run
+
+```bash
+dotnet run # original SOAP SDK (Bricknode.Soap.Sdk)
+dotnet run -p:UseRest=true # REST drop-in (Bricknode.Rest.CompatSdk)
+```
+
+The switch swaps the `` in the csproj. In a real application that is the
+`` you change — `Program.cs` and `TestRunner.cs` are untouched. That is the
+whole migration.
+
+> **Note:** the flavor is baked in at **build** time — don't combine the switch with `--no-build`,
+> or you'll run whichever flavor was built last.
+
+## Known differences when migrating
+
+- `EndpointAddress` must point at the REST base URL instead of the SOAP `.asmx`
+ (this client reads `SoapEndpoint`/`RestEndpoint` from config accordingly).
+- **Dates:** none — `DateTime` values (UTC `Kind=Utc`, unset dates as `DateTime.MinValue`) are
+ identical to what the SOAP SDK produces, guaranteed by `SoapRestWireParityTests` which runs the
+ same server data through the real `XmlSerializer` and the REST pipeline and requires
+ byte-identical results.
+
+## Configure credentials (never committed)
+
+Copy the template and fill it in:
+
+```bash
+cp appsettings.example.json appsettings.json
+```
+
+`appsettings.json` and `appsettings.local.json` are git-ignored. Alternatively use environment
+variables: `BFS_Username`, `BFS_Password`, `BFS_Identifier`, `BFS_SoapEndpoint`, `BFS_RestEndpoint`.
+
+## What it runs
+
+Four read-only cases in `TestRunner.cs`, each printing the response message, result count and a
+small sample so you can compare SOAP vs REST output side by side:
+
+- `IBfsAccountService.GetAccountsAsync`
+- `IBfsAccountService.GetAccountTypesAsync`
+- `IBfsCurrencyService.GetCurrenciesAsync`
+- `IBfsAccountService.GetAccountsAsync` with a `CreatedDateFrom` filter — prints the date sent and
+ the dates received (value + `Kind`), proving the server applies the date and both SDKs see
+ identical values in both directions.
diff --git a/Bricknode.MigrationTestClient/TestRunner.cs b/Bricknode.MigrationTestClient/TestRunner.cs
new file mode 100644
index 0000000..f8b80d5
--- /dev/null
+++ b/Bricknode.MigrationTestClient/TestRunner.cs
@@ -0,0 +1,92 @@
+using BfsApi;
+using Bricknode.Soap.Sdk.Services;
+
+namespace Bricknode.MigrationTestClient;
+
+///
+/// The verification cases, written exactly as a consumer of the SDK writes them: services are
+/// constructor-injected, DTOs come from BfsApi. This file compiles unchanged against both
+/// the SOAP package and the REST drop-in — the only thing that changes is the package reference.
+///
+internal sealed class TestRunner
+{
+ private readonly IBfsAccountService _accounts;
+ private readonly IBfsCurrencyService _currencies;
+
+ public TestRunner(IBfsAccountService accounts, IBfsCurrencyService currencies)
+ {
+ _accounts = accounts;
+ _currencies = currencies;
+ }
+
+ public async Task RunAsync()
+ {
+ await GetAccounts();
+ await GetAccountTypes();
+ await GetCurrencies();
+ await GetAccountsCreatedSince();
+ }
+
+ ///
+ /// Sends a date to the server (CreatedDateFrom filter) and shows the date sent and the dates
+ /// received back, so SOAP and REST date handling can be compared end to end.
+ ///
+ private async Task GetAccountsCreatedSince()
+ {
+ var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ var response = await _accounts.GetAccountsAsync(new GetAccountsArgs { CreatedDateFrom = from });
+
+ Print("GetAccounts (CreatedDateFrom filter)", response.Message, response.Result?.Length ?? 0,
+ response.Result?.Take(3).Select(a => a.AccountNo));
+ Console.WriteLine($" sent : CreatedDateFrom = {Show(from)}");
+
+ if (response.Result is { Length: > 0 })
+ {
+ var oldest = response.Result.Min(a => a.CreatedDate);
+ var newest = response.Result.Max(a => a.CreatedDate);
+ Console.WriteLine($" received: oldest CreatedDate = {Show(oldest)}");
+ Console.WriteLine($" received: newest CreatedDate = {Show(newest)}");
+ Console.WriteLine($" filter respected by server : {oldest >= from}");
+ }
+ }
+
+ private static string Show(DateTime value) => $"{value:yyyy-MM-dd HH:mm:ss.fff} (Kind={value.Kind})";
+
+ private async Task GetAccounts()
+ {
+ var response = await _accounts.GetAccountsAsync(new GetAccountsArgs());
+
+ Print("GetAccounts", response.Message, response.Result?.Length ?? 0,
+ response.Result?.Take(5).Select(a => a.AccountNo));
+ }
+
+ private async Task GetAccountTypes()
+ {
+ var response = await _accounts.GetAccountTypesAsync(new GetAccountTypeArgs());
+
+ Print("GetAccountTypes", response.Message, response.Result?.Length ?? 0,
+ response.Result?.Take(5).Select(t => t.Key));
+ }
+
+ private async Task GetCurrencies()
+ {
+ var response = await _currencies.GetCurrenciesAsync(new GetCurrencyArgs());
+
+ Print("GetCurrencies", response.Message, response.Result?.Length ?? 0,
+ response.Result?.Take(5).Select(c => c.Code));
+ }
+
+ private static void Print(string title, string? message, int count, IEnumerable? sample)
+ {
+ var ok = message == "OK";
+ Console.WriteLine();
+ Console.WriteLine($"[{(ok ? "OK" : "FAIL")}] {title}");
+ Console.WriteLine($" message : {message}");
+ Console.WriteLine($" results : {count}");
+
+ var items = sample?.Where(s => !string.IsNullOrWhiteSpace(s)).ToList();
+ if (items is { Count: > 0 })
+ Console.WriteLine($" sample : {string.Join(", ", items)}");
+ }
+}
diff --git a/Bricknode.MigrationTestClient/appsettings.example.json b/Bricknode.MigrationTestClient/appsettings.example.json
new file mode 100644
index 0000000..0b6a3ac
--- /dev/null
+++ b/Bricknode.MigrationTestClient/appsettings.example.json
@@ -0,0 +1,8 @@
+{
+ "_comment": "Copy this file to appsettings.json (git-ignored) and fill in real values. Never commit credentials.",
+ "Username": "REPLACE_ME",
+ "Password": "REPLACE_ME",
+ "Identifier": "REPLACE_ME",
+ "SoapEndpoint": "https://your-instance.bricknode.com/api/BFSApi.asmx",
+ "RestEndpoint": "https://your-instance.bricknode.com/"
+}
diff --git a/Bricknode.Rest.CompatSdk.Tests/BfsJsonMapperTests.cs b/Bricknode.Rest.CompatSdk.Tests/BfsJsonMapperTests.cs
new file mode 100644
index 0000000..36d228e
--- /dev/null
+++ b/Bricknode.Rest.CompatSdk.Tests/BfsJsonMapperTests.cs
@@ -0,0 +1,235 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using BfsApi;
+using CompatSdkTests.TestSupport;
+using Bricknode.Soap.Sdk.Mapping;
+using Xunit;
+using Xunit.Abstractions;
+using RestApi = global::Bricknode.Rest.CompatSdk;
+
+namespace CompatSdkTests;
+
+///
+/// Validates the JSON-bridge mapper used by the REST compat layer:
+/// SOAP-shaped BfsApi.* DTOs must survive a SOAP -> REST -> SOAP round-trip unchanged.
+///
+public class BfsJsonMapperTests
+{
+ private readonly ITestOutputHelper _output;
+
+ public BfsJsonMapperTests(ITestOutputHelper output)
+ {
+ _output = output;
+ }
+
+ // ---- Diagnostic: which BfsApi request/response types have NO REST counterpart? ----
+ // These are SOAP operations that cannot be ported by name as-is (the REST op may be absent,
+ // renamed, or merged). Informational only - stays green; writes the list to a file artifact.
+
+ [Fact]
+ public void Report_Soap_RequestResponse_Types_Without_Rest_Counterpart()
+ {
+ var assembly = typeof(RestApi.AccountsClient).Assembly;
+ var restNames = RestTypeNames(assembly);
+
+ var soapTypes = SoapRequestResponseTypes(assembly).ToList();
+ var unmatched = soapTypes
+ .Where(t => !restNames.Contains(t.Name))
+ .Select(t => t.Name)
+ .OrderBy(n => n, StringComparer.Ordinal)
+ .ToList();
+
+ var matchedCount = soapTypes.Count - unmatched.Count;
+
+ var report =
+ $"BfsApi request/response types: {soapTypes.Count} total, " +
+ $"{matchedCount} matched to a REST type, {unmatched.Count} unmatched.\n" +
+ "Unmatched (no REST type with the same name):\n" +
+ (unmatched.Count == 0 ? " (none)" : string.Join("\n", unmatched.Select(n => " " + n)));
+
+ _output.WriteLine(report);
+
+ var path = Path.Combine(AppContext.BaseDirectory, "unmatched-soap-request-response.txt");
+ File.WriteAllText(path, report);
+ _output.WriteLine("Written to: " + path);
+ }
+
+ // ---- Round-trip fidelity for EVERY BfsApi request/response with a REST counterpart ----
+ // SOAP -> REST -> SOAP must "look the same". Data-driven so new types are covered automatically.
+
+ [Theory]
+ [MemberData(nameof(RequestResponsePairs))]
+ public void RequestResponse_RoundTrips(TypePair pair)
+ {
+ var original = ObjectFiller.Create(pair.Soap);
+ Assert.NotNull(original);
+
+ var rest = MapDynamic(original!, pair.Rest);
+ Assert.NotNull(rest);
+
+ var roundTripped = MapDynamic(rest!, pair.Soap);
+ Assert.NotNull(roundTripped);
+ Assert.NotNull(original);
+
+ // DateTimes are compared by instant: the mapper intentionally converts zoned values to
+ // machine-local Kind=Local (SOAP SDK parity — see SoapRestWireParityTests for the strict
+ // face/Kind guarantee against the legacy XmlSerializer behavior).
+ JsonEquivalence.AssertEquivalent(original, roundTripped, normalizeDateTimes: true);
+ Assert.NotEqual(original, roundTripped);
+ }
+
+ public static IEnumerable