diff --git a/CHANGELOG.md b/CHANGELOG.md index ba6420f..d9b8dac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ 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`. +- 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`. + +### Fixed +- 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 - 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. 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/FacturapiTest/WrapperBehaviorTests.cs b/FacturapiTest/WrapperBehaviorTests.cs index 869090d..6e38009 100644 --- a/FacturapiTest/WrapperBehaviorTests.cs +++ b/FacturapiTest/WrapperBehaviorTests.cs @@ -301,6 +301,108 @@ 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 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 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_SerializesArrayParamsWithRepeatedKeys() + { + var handler = new RecordingHandler((request, cancellationToken) => + { + Assert.Equal(HttpMethod.Get, request.Method); + Assert.NotNull(request.RequestUri); + Assert.Equal( + "/v2/invoices?status=valid&status=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 b0341a4..1964a0b 100644 --- a/Models/SearchResult.cs +++ b/Models/SearchResult.cs @@ -1,12 +1,16 @@ -using System.Collections.Generic; +using System.Collections.Generic; 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; } public List Data { get; set; } } } diff --git a/Router/Router.cs b/Router/Router.cs index 06228eb..6bfbcf0 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,46 @@ 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) + { + // 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) + { + 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..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.8.0 + 7.0.0 $(Version) MIT false