diff --git a/CHANGELOG.md b/CHANGELOG.md index b103992..c6dbb03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/FacturapiTest/WrapperBehaviorTests.cs b/FacturapiTest/WrapperBehaviorTests.cs index f03fddb..b433691 100644 --- a/FacturapiTest/WrapperBehaviorTests.cs +++ b/FacturapiTest/WrapperBehaviorTests.cs @@ -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 { ["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 + { + ["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 + { + ["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() { diff --git a/Router/RetentionRouter.cs b/Router/RetentionRouter.cs index 4f655ee..bf5d8ff 100644 --- a/Router/RetentionRouter.cs +++ b/Router/RetentionRouter.cs @@ -28,6 +28,21 @@ public static string CancelRetention(string id, Dictionary query return UriWithQuery(RetrieveRetention(id), query); } + public static string UpdateDraftRetention(string id) + { + return RetrieveRetention(id); + } + + public static string StampDraftRetention(string id, Dictionary 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}"; diff --git a/Wrappers/IRetentionWrapper.cs b/Wrappers/IRetentionWrapper.cs index 9359c4d..31d05c7 100644 --- a/Wrappers/IRetentionWrapper.cs +++ b/Wrappers/IRetentionWrapper.cs @@ -12,6 +12,9 @@ public interface IRetentionWrapper Task RetrieveAsync(string id, CancellationToken cancellationToken = default); Task CancelAsync(string id, Dictionary query = null, CancellationToken cancellationToken = default); Task SendByEmailAsync(string id, Dictionary data = null, CancellationToken cancellationToken = default); + Task UpdateDraftAsync(string id, Dictionary data, CancellationToken cancellationToken = default); + Task StampDraftAsync(string id, Dictionary options = null, CancellationToken cancellationToken = default); + Task CopyToDraftAsync(string id, CancellationToken cancellationToken = default); Task DownloadZipAsync(string id, CancellationToken cancellationToken = default); Task DownloadPdfAsync(string id, CancellationToken cancellationToken = default); Task DownloadXmlAsync(string id, CancellationToken cancellationToken = default); diff --git a/Wrappers/RetentionWrapper.cs b/Wrappers/RetentionWrapper.cs index 607d31e..157f0c9 100644 --- a/Wrappers/RetentionWrapper.cs +++ b/Wrappers/RetentionWrapper.cs @@ -69,6 +69,42 @@ public async Task SendByEmailAsync(string id, Dictionary data = } } + public async Task UpdateDraftAsync(string id, Dictionary 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(resultString, this.jsonSettings); + return retention; + } + } + + public async Task StampDraftAsync(string id, Dictionary 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(resultString, this.jsonSettings); + return retention; + } + } + + public async Task 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(resultString, this.jsonSettings); + return retention; + } + } + private async Task DownloadAsync(string id, string format, CancellationToken cancellationToken) { using (var response = await client.GetAsync(Router.DownloadRetention(id, format), cancellationToken).ConfigureAwait(false)) diff --git a/facturapi-net.csproj b/facturapi-net.csproj index 5a243c5..a02772a 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.5.0 + 6.6.0 $(Version) MIT false