Skip to content
Merged
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 14 additions & 1 deletion FacturapiTest/ClientCompatibilityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ namespace FacturapiTest
public class ClientCompatibilityTests
{
[Fact]
public void Router_ListCustomers_AllowsNullQueryValues()
public void Router_ListCustomers_SkipsNullQueryValues()
{
var query = new Dictionary<string, object>
{
Expand All @@ -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<string, object>
{
["foo"] = ""
};

var url = Router.ListCustomers(query);

Assert.Equal("customers?foo=", url);
}

Expand Down
102 changes: 102 additions & 0 deletions FacturapiTest/WrapperBehaviorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object>
{
["limit"] = 100,
["date"] = new Dictionary<string, object>
{
["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<string, object>
{
["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<string, object>
{
["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<string, object>
{
["status"] = new List<string> { "valid", "canceled" }
});

Assert.NotNull(result);
Assert.NotNull(result.Data);
}

[Fact]
public async Task RetentionCreateAsync_CanCreateDraft()
{
Expand Down
12 changes: 8 additions & 4 deletions Models/SearchResult.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
using System.Collections.Generic;
using System.Collections.Generic;

namespace Facturapi
{
public class SearchResult<T>
{
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; }
Comment thread
javorosas marked this conversation as resolved.
public List<T> Data { get; set; }
}
}
51 changes: 42 additions & 9 deletions Router/Router.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;

Expand All @@ -24,14 +25,46 @@ private static string UriWithQuery(string path, Dictionary<string, object> query

private static string DictionaryToQueryString(Dictionary<string, object> 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<string>();
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<string> 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));
}
}
}
2 changes: 1 addition & 1 deletion facturapi-net.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<Summary>SDK oficial de Facturapi para .NET para facturación electrónica en México (CFDI), envío de documentos, búsqueda y trazabilidad.</Summary>
<PackageTags>factura factura-electronica facturacion cfdi cfdi40 sat invoice invoicing facturapi mexico</PackageTags>
<Title>Facturapi</Title>
<Version>6.8.0</Version>
<Version>7.0.0</Version>
<PackageVersion>$(Version)</PackageVersion>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
Expand Down
Loading