Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ 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.6.0] - 2026-07-01
### Added
- Added retention draft methods: `UpdateDraftAsync`, `StampDraftAsync`, and `CopyToDraftAsync`.

## [6.5.0] - 2026-06-07
### Added
- Expose structured API error metadata on `FacturapiException`, including `Code`, `Path`, `Location`, `Errors`, `LogId`, and response `Headers`.
Expand Down
102 changes: 102 additions & 0 deletions FacturapiTest/WrapperBehaviorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,108 @@ public async Task RetentionListAsync_UsesRetentionsRoute()
Assert.NotNull(result.Data);
}

[Fact]
public async Task RetentionListAsync_CanFilterDrafts()
{
var handler = new RecordingHandler((request, cancellationToken) =>
{
Assert.Equal(HttpMethod.Get, request.Method);
Assert.NotNull(request.RequestUri);
Assert.Equal("/v2/retentions?status=draft", request.RequestUri.PathAndQuery);
return Task.FromResult(JsonResponse("{\"data\":[]}"));
});

var wrapper = new RetentionWrapper("test_key", "v2", CreateHttpClient(handler));
var result = await wrapper.ListAsync(new Dictionary<string, object> { ["status"] = "draft" });

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

[Fact]
public async Task RetentionCreateAsync_CanCreateDraft()
{
var handler = new RecordingHandler(async (request, cancellationToken) =>
{
Assert.Equal(HttpMethod.Post, request.Method);
Assert.NotNull(request.RequestUri);
Assert.Equal("/v2/retentions", request.RequestUri.PathAndQuery);
Assert.NotNull(request.Content);
var body = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
Assert.Contains("\"status\":\"draft\"", body);
Assert.Contains("\"customer\":null", body);

return JsonResponse("{\"id\":\"ret_123\"}");
});

var wrapper = new RetentionWrapper("test_key", "v2", CreateHttpClient(handler));
var result = await wrapper.CreateAsync(new Dictionary<string, object>
{
["status"] = "draft",
["customer"] = null!
});

Assert.Equal("ret_123", result.Id);
}

[Fact]
public async Task RetentionUpdateDraftAsync_UsesRetentionRoute()
{
var handler = new RecordingHandler(async (request, cancellationToken) =>
{
Assert.Equal(HttpMethod.Put, request.Method);
Assert.NotNull(request.RequestUri);
Assert.Equal("/v2/retentions/ret_123", request.RequestUri.PathAndQuery);
Assert.NotNull(request.Content);
var body = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
Assert.Contains("\"folio_int\":\"R-2026-001\"", body);

return JsonResponse("{\"id\":\"ret_123\"}");
});

var wrapper = new RetentionWrapper("test_key", "v2", CreateHttpClient(handler));
var result = await wrapper.UpdateDraftAsync("ret_123", new Dictionary<string, object>
{
["folio_int"] = "R-2026-001"
});

Assert.Equal("ret_123", result.Id);
}

[Fact]
public async Task RetentionCopyToDraftAsync_UsesCopyRoute()
{
var handler = new RecordingHandler((request, cancellationToken) =>
{
Assert.Equal(HttpMethod.Post, request.Method);
Assert.NotNull(request.RequestUri);
Assert.Equal("/v2/retentions/ret_123/copy", request.RequestUri.PathAndQuery);
return Task.FromResult(JsonResponse("{\"id\":\"ret_copy\"}"));
});

var wrapper = new RetentionWrapper("test_key", "v2", CreateHttpClient(handler));
var result = await wrapper.CopyToDraftAsync("ret_123");

Assert.Equal("ret_copy", result.Id);
}

[Fact]
public async Task RetentionStampDraftAsync_UsesStampRoute()
{
var handler = new RecordingHandler((request, cancellationToken) =>
{
Assert.Equal(HttpMethod.Post, request.Method);
Assert.NotNull(request.RequestUri);
Assert.Equal("/v2/retentions/ret_123/stamp", request.RequestUri.PathAndQuery);
return Task.FromResult(JsonResponse("{\"id\":\"ret_123\"}"));
});

var wrapper = new RetentionWrapper("test_key", "v2", CreateHttpClient(handler));
var result = await wrapper.StampDraftAsync("ret_123");

Assert.Equal("ret_123", result.Id);
}

[Fact]
public async Task ErrorMapping_UsesStatusFromString()
{
Expand Down
15 changes: 15 additions & 0 deletions Router/RetentionRouter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@ public static string CancelRetention(string id, Dictionary<string, object> query
return UriWithQuery(RetrieveRetention(id), query);
}

public static string UpdateDraftRetention(string id)
{
return RetrieveRetention(id);
}

public static string StampDraftRetention(string id, Dictionary<string, object> query = null)
{
return UriWithQuery($"retentions/{id}/stamp", query);
}

public static string CopyRetention(string id)
{
return $"retentions/{id}/copy";
}

public static string DownloadRetention(string id, string format)
{
return $"retentions/{id}/{format}";
Expand Down
3 changes: 3 additions & 0 deletions Wrappers/IRetentionWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ public interface IRetentionWrapper
Task<Invoice> RetrieveAsync(string id, CancellationToken cancellationToken = default);
Task<Invoice> CancelAsync(string id, Dictionary<string, object> query = null, CancellationToken cancellationToken = default);
Task SendByEmailAsync(string id, Dictionary<string, object> data = null, CancellationToken cancellationToken = default);
Task<Invoice> UpdateDraftAsync(string id, Dictionary<string, object> data, CancellationToken cancellationToken = default);
Task<Invoice> StampDraftAsync(string id, Dictionary<string, object> options = null, CancellationToken cancellationToken = default);
Task<Invoice> CopyToDraftAsync(string id, CancellationToken cancellationToken = default);
Task<Stream> DownloadZipAsync(string id, CancellationToken cancellationToken = default);
Task<Stream> DownloadPdfAsync(string id, CancellationToken cancellationToken = default);
Task<Stream> DownloadXmlAsync(string id, CancellationToken cancellationToken = default);
Expand Down
36 changes: 36 additions & 0 deletions Wrappers/RetentionWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,42 @@ public async Task SendByEmailAsync(string id, Dictionary<string, object> data =
}
}

public async Task<Invoice> UpdateDraftAsync(string id, Dictionary<string, object> data, CancellationToken cancellationToken = default)
{
using (var content = new StringContent(JsonConvert.SerializeObject(data), Encoding.UTF8, "application/json"))
using (var response = await client.PutAsync(Router.UpdateDraftRetention(id), content, cancellationToken).ConfigureAwait(false))
{
await this.ThrowIfErrorAsync(response, cancellationToken).ConfigureAwait(false);
var resultString = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var retention = JsonConvert.DeserializeObject<Invoice>(resultString, this.jsonSettings);
return retention;
}
}

public async Task<Invoice> StampDraftAsync(string id, Dictionary<string, object> options = null, CancellationToken cancellationToken = default)
{
using (var content = new StringContent("", Encoding.UTF8, "application/json"))
using (var response = await client.PostAsync(Router.StampDraftRetention(id, options), content, cancellationToken).ConfigureAwait(false))
{
await this.ThrowIfErrorAsync(response, cancellationToken).ConfigureAwait(false);
var resultString = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var retention = JsonConvert.DeserializeObject<Invoice>(resultString, this.jsonSettings);
return retention;
}
}

public async Task<Invoice> CopyToDraftAsync(string id, CancellationToken cancellationToken = default)
{
using (var content = new StringContent("", Encoding.UTF8, "application/json"))
using (var response = await client.PostAsync(Router.CopyRetention(id), content, cancellationToken).ConfigureAwait(false))
{
await this.ThrowIfErrorAsync(response, cancellationToken).ConfigureAwait(false);
var resultString = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var retention = JsonConvert.DeserializeObject<Invoice>(resultString, this.jsonSettings);
return retention;
}
}

private async Task<Stream> DownloadAsync(string id, string format, CancellationToken cancellationToken)
{
using (var response = await client.GetAsync(Router.DownloadRetention(id, format), cancellationToken).ConfigureAwait(false))
Expand Down
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.5.0</Version>
<Version>6.6.0</Version>
<PackageVersion>$(Version)</PackageVersion>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
Expand Down
Loading