From 7e7eec5a434be2e7c30e82a1e0912a07e5138561 Mon Sep 17 00:00:00 2001 From: javorosas Date: Wed, 9 Sep 2026 13:14:10 +0200 Subject: [PATCH 1/9] fix: serialize nested query dictionaries with bracket notation Query values that were dictionaries or lists were sent through ToString(), producing garbage for a date range (Dictionary.ToString()) and for arrays (List.ToString()). Recurse into IDictionary values expanding them to bracket keys (date[gte]=...&date[lt]=...), expand non-string enumerables to repeated empty-bracket keys (status[]=...), and keep null values serialized as empty (foo=), matching the v2 API contract. Bump to 6.8.1 and add a regression test. --- FacturapiTest/WrapperBehaviorTests.cs | 28 +++++++++++++++ Router/Router.cs | 49 ++++++++++++++++++++++----- facturapi-net.csproj | 2 +- 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/FacturapiTest/WrapperBehaviorTests.cs b/FacturapiTest/WrapperBehaviorTests.cs index 869090d..6898920 100644 --- a/FacturapiTest/WrapperBehaviorTests.cs +++ b/FacturapiTest/WrapperBehaviorTests.cs @@ -301,6 +301,34 @@ public async Task RetentionListAsync_CanFilterDrafts() Assert.NotNull(result.Data); } + [Fact] + public async Task InvoiceListAsync_SerializesNestedDateRangeWithBracketNotation() + { + var handler = new RecordingHandler((request, cancellationToken) => + { + Assert.Equal(HttpMethod.Get, request.Method); + Assert.NotNull(request.RequestUri); + Assert.Equal( + "/v2/invoices?limit=100&date%5Bgte%5D=2026-01-01&date%5Blt%5D=2026-02-01", + request.RequestUri.PathAndQuery); + return Task.FromResult(JsonResponse("{\"data\":[]}")); + }); + + var wrapper = new InvoiceWrapper("test_key", "v2", CreateHttpClient(handler)); + var result = await wrapper.ListAsync(new Dictionary + { + ["limit"] = 100, + ["date"] = new Dictionary + { + ["gte"] = "2026-01-01", + ["lt"] = "2026-02-01" + } + }); + + Assert.NotNull(result); + Assert.NotNull(result.Data); + } + [Fact] public async Task RetentionCreateAsync_CanCreateDraft() { diff --git a/Router/Router.cs b/Router/Router.cs index 06228eb..59cb700 100644 --- a/Router/Router.cs +++ b/Router/Router.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Collections; using System.Collections.Generic; using System.Linq; @@ -24,14 +25,44 @@ private static string UriWithQuery(string path, Dictionary query private static string DictionaryToQueryString(Dictionary dict) { - return String.Join( - "&", - dict - .Where(x => !String.IsNullOrEmpty(x.Key)) - .Select(x => String.Format( - "{0}={1}", - Uri.EscapeDataString(x.Key), - Uri.EscapeDataString(x.Value?.ToString() ?? String.Empty)))); + var parts = new List(); + foreach (var entry in dict.Where(x => !String.IsNullOrEmpty(x.Key))) + { + AppendQueryPart(parts, entry.Key, entry.Value); + } + + return String.Join("&", parts); + } + + private static void AppendQueryPart(List parts, string key, object value) + { + if (value == null) + { + parts.Add(Uri.EscapeDataString(key) + "="); + return; + } + + if (value is IDictionary dictionary) + { + foreach (DictionaryEntry entry in dictionary) + { + AppendQueryPart(parts, key + "[" + entry.Key + "]", entry.Value); + } + + return; + } + + if (!(value is string) && value is IEnumerable enumerable) + { + foreach (var item in enumerable) + { + AppendQueryPart(parts, key + "[]", item); + } + + return; + } + + parts.Add(Uri.EscapeDataString(key) + "=" + Uri.EscapeDataString(value.ToString() ?? String.Empty)); } } } diff --git a/facturapi-net.csproj b/facturapi-net.csproj index e7c50f6..8f203ae 100644 --- a/facturapi-net.csproj +++ b/facturapi-net.csproj @@ -11,7 +11,7 @@ SDK oficial de Facturapi para .NET para facturación electrónica en México (CFDI), envío de documentos, búsqueda y trazabilidad. factura factura-electronica facturacion cfdi cfdi40 sat invoice invoicing facturapi mexico Facturapi - 6.8.0 + 6.8.1 $(Version) MIT false From 8f3411585494e98823e368d82471d045322a50e8 Mon Sep 17 00:00:00 2001 From: javorosas Date: Wed, 9 Sep 2026 14:42:38 +0200 Subject: [PATCH 2/9] feat(models): expose cursor and capped-total metadata on search results SearchResult gains TotalsAreCapped, NextCursor, and PreviousCursor so callers can follow cursor pagination and detect capped totals once the capping wave is live (page totals capped; cursor mode returns totals only on the first page). --- FacturapiTest/WrapperBehaviorTests.cs | 24 ++++++++++++++++++++++++ Models/SearchResult.cs | 5 ++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/FacturapiTest/WrapperBehaviorTests.cs b/FacturapiTest/WrapperBehaviorTests.cs index 6898920..09cdc44 100644 --- a/FacturapiTest/WrapperBehaviorTests.cs +++ b/FacturapiTest/WrapperBehaviorTests.cs @@ -329,6 +329,30 @@ public async Task InvoiceListAsync_SerializesNestedDateRangeWithBracketNotation( Assert.NotNull(result.Data); } + [Fact] + public async Task InvoiceListAsync_MapsPaginationMetadata() + { + var handler = new RecordingHandler((request, cancellationToken) => + { + Assert.Equal(HttpMethod.Get, request.Method); + Assert.NotNull(request.RequestUri); + Assert.Equal("/v2/invoices?limit=100", request.RequestUri.PathAndQuery); + return Task.FromResult(JsonResponse("{\"data\":[],\"total_results\":3000,\"totals_are_capped\":true,\"next_cursor\":\"next-1\",\"previous_cursor\":null}")); + }); + + var wrapper = new InvoiceWrapper("test_key", "v2", CreateHttpClient(handler)); + var result = await wrapper.ListAsync(new Dictionary + { + ["limit"] = 100 + }); + + Assert.NotNull(result); + Assert.Equal(3000, result.TotalResults); + Assert.True(result.TotalsAreCapped); + Assert.Equal("next-1", result.NextCursor); + Assert.Null(result.PreviousCursor); + } + [Fact] public async Task RetentionCreateAsync_CanCreateDraft() { diff --git a/Models/SearchResult.cs b/Models/SearchResult.cs index b0341a4..b57e2e4 100644 --- a/Models/SearchResult.cs +++ b/Models/SearchResult.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace Facturapi { @@ -7,6 +7,9 @@ public class SearchResult public int Page { get; set; } public int TotalPages { get; set; } public int TotalResults { get; set; } + public bool? TotalsAreCapped { get; set; } + public string NextCursor { get; set; } + public string PreviousCursor { get; set; } public List Data { get; set; } } } From 170043b00cdd2e6aa85f70ac3f8c53a4ef00a0ed Mon Sep 17 00:00:00 2001 From: javorosas Date: Wed, 9 Sep 2026 15:28:12 +0200 Subject: [PATCH 3/9] chore: bump to 6.9.0 (minor) The nested query serialization fix is a patch, but the new cursor and capped-total properties on SearchResult are additive public API, so the release is a minor per semver, consistent with the node SDK bump. --- facturapi-net.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/facturapi-net.csproj b/facturapi-net.csproj index 8f203ae..7aa8dbf 100644 --- a/facturapi-net.csproj +++ b/facturapi-net.csproj @@ -11,7 +11,7 @@ SDK oficial de Facturapi para .NET para facturación electrónica en México (CFDI), envío de documentos, búsqueda y trazabilidad. factura factura-electronica facturacion cfdi cfdi40 sat invoice invoicing facturapi mexico Facturapi - 6.8.1 + 6.9.0 $(Version) MIT false From 67878a0c15b42d3c7132c28223c1619ff8c15002 Mon Sep 17 00:00:00 2001 From: javorosas Date: Wed, 9 Sep 2026 15:49:07 +0200 Subject: [PATCH 4/9] docs(changelog): add 6.9.0 entry SearchResult cursor/capped metadata under Added; nested query param serialization fix under Fixed. --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba6420f..4bc1bff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [6.9.0] - 2026-09-09 +### Added +- Expose cursor pagination and capped-total metadata on `SearchResult`: `TotalsAreCapped`, `NextCursor`, and `PreviousCursor` so callers can follow cursor pagination and detect capped totals (page totals capped; cursor mode returns totals only on the first page). + +### Fixed +- Serialize nested and array query params with the bracket notation the API expects. Query values that were dictionaries or lists were sent through `ToString()` (for example a `date` range as `Dictionary`); they now expand to bracket keys (`date[gte]=...&date[lt]=...` and `status[]=...`). + ## [6.8.0] - 2026-09-04 ### Added - Added `GetPaymentSummaryAsync` to get the related-document object needed to build a payment complement (complemento de pago): installment number, previous balance, and taxes prorated to the paid amount. From 509cb0ba52ac79ed4c44650e8e33bd6046178a34 Mon Sep 17 00:00:00 2001 From: javorosas Date: Wed, 9 Sep 2026 22:02:15 +0200 Subject: [PATCH 5/9] fix(models): nullable page/totals + cover cursor and array serialization - SearchResult.Page/TotalPages/TotalResults are now nullable: later cursor pages omit them and deserializing absence as 0 misreported metadata. - Regression tests: a later-cursor response (only cursors + data) maps to null totals, and array params serialize to repeated bracket keys (status[]=valid&status[]=canceled), covering the IEnumerable branch. --- FacturapiTest/WrapperBehaviorTests.cs | 50 +++++++++++++++++++++++++++ Models/SearchResult.cs | 7 ++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/FacturapiTest/WrapperBehaviorTests.cs b/FacturapiTest/WrapperBehaviorTests.cs index 09cdc44..2737f76 100644 --- a/FacturapiTest/WrapperBehaviorTests.cs +++ b/FacturapiTest/WrapperBehaviorTests.cs @@ -353,6 +353,56 @@ public async Task InvoiceListAsync_MapsPaginationMetadata() Assert.Null(result.PreviousCursor); } + [Fact] + public async Task InvoiceListAsync_LaterCursorPageOmitsPageTotals() + { + var handler = new RecordingHandler((request, cancellationToken) => + { + Assert.Equal(HttpMethod.Get, request.Method); + Assert.NotNull(request.RequestUri); + Assert.Equal("/v2/invoices?pagination=cursor&after=next-1", request.RequestUri.PathAndQuery); + return Task.FromResult(JsonResponse("{\"data\":[{\"id\":\"inv_x\"}],\"next_cursor\":\"next-2\",\"previous_cursor\":\"next-1\"}")); + }); + + var wrapper = new InvoiceWrapper("test_key", "v2", CreateHttpClient(handler)); + var result = await wrapper.ListAsync(new Dictionary + { + ["pagination"] = "cursor", + ["after"] = "next-1" + }); + + Assert.NotNull(result); + Assert.Null(result.Page); + Assert.Null(result.TotalPages); + Assert.Null(result.TotalResults); + Assert.Equal("next-2", result.NextCursor); + Assert.Equal("next-1", result.PreviousCursor); + Assert.Single(result.Data); + } + + [Fact] + public async Task InvoiceListAsync_SerializesArrayParamsWithBracketKeys() + { + var handler = new RecordingHandler((request, cancellationToken) => + { + Assert.Equal(HttpMethod.Get, request.Method); + Assert.NotNull(request.RequestUri); + Assert.Equal( + "/v2/invoices?status%5B%5D=valid&status%5B%5D=canceled", + request.RequestUri.PathAndQuery); + return Task.FromResult(JsonResponse("{\"data\":[]}")); + }); + + var wrapper = new InvoiceWrapper("test_key", "v2", CreateHttpClient(handler)); + var result = await wrapper.ListAsync(new Dictionary + { + ["status"] = new List { "valid", "canceled" } + }); + + Assert.NotNull(result); + Assert.NotNull(result.Data); + } + [Fact] public async Task RetentionCreateAsync_CanCreateDraft() { diff --git a/Models/SearchResult.cs b/Models/SearchResult.cs index b57e2e4..1964a0b 100644 --- a/Models/SearchResult.cs +++ b/Models/SearchResult.cs @@ -4,9 +4,10 @@ namespace Facturapi { public class SearchResult { - public int Page { get; set; } - public int TotalPages { get; set; } - public int TotalResults { get; set; } + // Nullable: cursor responses after the first page omit page/totals. + public int? Page { get; set; } + public int? TotalPages { get; set; } + public int? TotalResults { get; set; } public bool? TotalsAreCapped { get; set; } public string NextCursor { get; set; } public string PreviousCursor { get; set; } From 38be2c5d700011138e23dca9327e746ebf7c1f24 Mon Sep 17 00:00:00 2001 From: javorosas Date: Wed, 9 Sep 2026 22:31:27 +0200 Subject: [PATCH 6/9] chore: release 7.0.0 (major) for nullable SearchResult totals Changing Page/TotalPages/TotalResults from int to int? alters public member signatures, so the release is a major (7.0.0), with the additive cursor/capped metadata under Added and the serialization fix under Fixed. --- CHANGELOG.md | 10 ++++++++++ facturapi-net.csproj | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bc1bff..f811e24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [7.0.0] - 2026-09-09 +### Breaking +- `SearchResult.Page`, `TotalPages`, and `TotalResults` are now nullable (`int?`): cursor responses after the first page omit them and absence must not deserialize as `0`. + +### Added +- Expose cursor pagination and capped-total metadata on `SearchResult`: `TotalsAreCapped`, `NextCursor`, and `PreviousCursor`. + +### Fixed +- Serialize nested and array query params with the bracket notation the API expects (dictionaries/lists used to be sent through `ToString()`). + ## [6.9.0] - 2026-09-09 ### Added - Expose cursor pagination and capped-total metadata on `SearchResult`: `TotalsAreCapped`, `NextCursor`, and `PreviousCursor` so callers can follow cursor pagination and detect capped totals (page totals capped; cursor mode returns totals only on the first page). diff --git a/facturapi-net.csproj b/facturapi-net.csproj index 7aa8dbf..f95d0a8 100644 --- a/facturapi-net.csproj +++ b/facturapi-net.csproj @@ -11,7 +11,7 @@ SDK oficial de Facturapi para .NET para facturación electrónica en México (CFDI), envío de documentos, búsqueda y trazabilidad. factura factura-electronica facturacion cfdi cfdi40 sat invoice invoicing facturapi mexico Facturapi - 6.9.0 + 7.0.0 $(Version) MIT false From d684018c5d6d4c94fb1ec537ea13522dbbff1715 Mon Sep 17 00:00:00 2001 From: javorosas Date: Thu, 10 Sep 2026 02:23:44 +0200 Subject: [PATCH 7/9] docs(changelog): drop duplicate 6.9.0 section (work released as 7.0.0) --- CHANGELOG.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f811e24..7eb0365 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,13 +15,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Serialize nested and array query params with the bracket notation the API expects (dictionaries/lists used to be sent through `ToString()`). -## [6.9.0] - 2026-09-09 -### Added -- Expose cursor pagination and capped-total metadata on `SearchResult`: `TotalsAreCapped`, `NextCursor`, and `PreviousCursor` so callers can follow cursor pagination and detect capped totals (page totals capped; cursor mode returns totals only on the first page). - -### Fixed -- Serialize nested and array query params with the bracket notation the API expects. Query values that were dictionaries or lists were sent through `ToString()` (for example a `date` range as `Dictionary`); they now expand to bracket keys (`date[gte]=...&date[lt]=...` and `status[]=...`). - ## [6.8.0] - 2026-09-04 ### Added - Added `GetPaymentSummaryAsync` to get the related-document object needed to build a payment complement (complemento de pago): installment number, previous balance, and taxes prorated to the paid amount. From 0b9a2fce8f82f7e5ace35c67f3ec707463c90e8f Mon Sep 17 00:00:00 2001 From: javorosas Date: Thu, 10 Sep 2026 17:51:50 +0200 Subject: [PATCH 8/9] fix(router): repeat the key for list query params --- CHANGELOG.md | 2 +- FacturapiTest/WrapperBehaviorTests.cs | 4 ++-- Router/Router.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eb0365..5a7bbe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Expose cursor pagination and capped-total metadata on `SearchResult`: `TotalsAreCapped`, `NextCursor`, and `PreviousCursor`. ### Fixed -- Serialize nested and array query params with the bracket notation the API expects (dictionaries/lists used to be sent through `ToString()`). +- Serialize query params with the encoding the API documents: nested dictionaries use bracket notation (`date[gte]=...`) and lists repeat the key (`status=valid&status=canceled`), matching the other official SDKs (dictionaries/lists used to be sent through `ToString()`). ## [6.8.0] - 2026-09-04 ### Added diff --git a/FacturapiTest/WrapperBehaviorTests.cs b/FacturapiTest/WrapperBehaviorTests.cs index 2737f76..6e38009 100644 --- a/FacturapiTest/WrapperBehaviorTests.cs +++ b/FacturapiTest/WrapperBehaviorTests.cs @@ -381,14 +381,14 @@ public async Task InvoiceListAsync_LaterCursorPageOmitsPageTotals() } [Fact] - public async Task InvoiceListAsync_SerializesArrayParamsWithBracketKeys() + public async Task InvoiceListAsync_SerializesArrayParamsWithRepeatedKeys() { var handler = new RecordingHandler((request, cancellationToken) => { Assert.Equal(HttpMethod.Get, request.Method); Assert.NotNull(request.RequestUri); Assert.Equal( - "/v2/invoices?status%5B%5D=valid&status%5B%5D=canceled", + "/v2/invoices?status=valid&status=canceled", request.RequestUri.PathAndQuery); return Task.FromResult(JsonResponse("{\"data\":[]}")); }); diff --git a/Router/Router.cs b/Router/Router.cs index 59cb700..ee9baba 100644 --- a/Router/Router.cs +++ b/Router/Router.cs @@ -56,7 +56,7 @@ private static void AppendQueryPart(List parts, string key, object value { foreach (var item in enumerable) { - AppendQueryPart(parts, key + "[]", item); + AppendQueryPart(parts, key, item); } return; From 7062b765e318bf9483ab2d8a4f7065d80f39c4bb Mon Sep 17 00:00:00 2001 From: javorosas Date: Thu, 10 Sep 2026 18:14:38 +0200 Subject: [PATCH 9/9] fix(router): omit null query values instead of sending them empty --- CHANGELOG.md | 1 + FacturapiTest/ClientCompatibilityTests.cs | 15 ++++++++++++++- Router/Router.cs | 4 +++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a7bbe7..d9b8dac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [7.0.0] - 2026-09-09 ### Breaking - `SearchResult.Page`, `TotalPages`, and `TotalResults` are now nullable (`int?`): cursor responses after the first page omit them and absence must not deserialize as `0`. +- A `null` value in a query dictionary now omits the key instead of sending it empty (`foo=`), matching the other official SDKs. Pass an empty string to send an explicit empty value. ### Added - Expose cursor pagination and capped-total metadata on `SearchResult`: `TotalsAreCapped`, `NextCursor`, and `PreviousCursor`. diff --git a/FacturapiTest/ClientCompatibilityTests.cs b/FacturapiTest/ClientCompatibilityTests.cs index 9b01c39..af69126 100644 --- a/FacturapiTest/ClientCompatibilityTests.cs +++ b/FacturapiTest/ClientCompatibilityTests.cs @@ -14,7 +14,7 @@ namespace FacturapiTest public class ClientCompatibilityTests { [Fact] - public void Router_ListCustomers_AllowsNullQueryValues() + public void Router_ListCustomers_SkipsNullQueryValues() { var query = new Dictionary { @@ -24,6 +24,19 @@ public void Router_ListCustomers_AllowsNullQueryValues() var url = Router.ListCustomers(query); + Assert.Equal("customers", url); + } + + [Fact] + public void Router_ListCustomers_SendsExplicitEmptyQueryValues() + { + var query = new Dictionary + { + ["foo"] = "" + }; + + var url = Router.ListCustomers(query); + Assert.Equal("customers?foo=", url); } diff --git a/Router/Router.cs b/Router/Router.cs index ee9baba..6bfbcf0 100644 --- a/Router/Router.cs +++ b/Router/Router.cs @@ -36,9 +36,11 @@ private static string DictionaryToQueryString(Dictionary dict) private static void AppendQueryPart(List parts, string key, object value) { + // A null value means "no filter": omit the key instead of sending it + // empty, which the API can read as an explicit empty value. Pass an + // empty string to send `key=` on purpose. if (value == null) { - parts.Add(Uri.EscapeDataString(key) + "="); return; }