Skip to content
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# SDK Contribution Notes

## Wrapper Interface Compatibility

- `IFacturapiClient` and the `I*Wrapper` interfaces are supported public contracts for injection and mocks.
- Adding an API capability to its existing `I*Wrapper` is an additive minor SDK change, not a breaking major-version change. Manual interface implementations must add the new members.
- Do not introduce capability interfaces solely to avoid extending an existing resource wrapper.
- For SDK tests, prefer injecting an `HttpClient` with a custom `HttpMessageHandler`. Applications that need a smaller dependency should define an abstraction in their own integration layer.
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).

## Unreleased
### Added
- Added invoice ZIP request methods: `CreateZipRequestAsync`, `ListZipRequestsAsync`, `RetrieveZipRequestAsync`, and `DownloadZipRequestAsync`.

## [6.6.0] - 2026-07-01
### Added
- Added retention draft methods: `UpdateDraftAsync`, `StampDraftAsync`, and `CopyToDraftAsync`.
Expand Down
98 changes: 98 additions & 0 deletions FacturapiTest/WrapperBehaviorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,104 @@ public async Task InvoiceDownloadPdfAsync_ReturnsSeekableStreamAtPositionZero()
Assert.Equal("pdf-bytes", text);
}

[Fact]
public async Task InvoiceCreateZipRequestAsync_UsesZipRequestsPostRoute()
{
var handler = new RecordingHandler(async (request, cancellationToken) =>
{
Assert.Equal(HttpMethod.Post, request.Method);
Assert.NotNull(request.RequestUri);
Assert.Equal("/v2/invoices/zip-requests", request.RequestUri.PathAndQuery);
Assert.NotNull(request.Content);
var body = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
Assert.Contains("\"year\":2025", body);
Assert.Contains("\"issuer_type\":\"issuing\"", body);
Assert.Contains("\"invoice_types\":[\"I\",\"E\"]", body);
return JsonResponse("{\"id\":\"zip_1\",\"status\":\"pending\"}");
});

var wrapper = new InvoiceWrapper("test_key", "v2", CreateHttpClient(handler));
var result = await wrapper.CreateZipRequestAsync(new Dictionary<string, object>
{
["year"] = 2025,
["month"] = 3,
["issuer_type"] = "issuing",
["invoice_types"] = new[] { "I", "E" }
});

Assert.Equal("zip_1", result["id"]?.ToString());
}

[Fact]
public async Task InvoiceListZipRequestsAsync_UsesZipRequestsQueryRoute()
{
var handler = new RecordingHandler((request, cancellationToken) =>
{
Assert.Equal(HttpMethod.Get, request.Method);
Assert.NotNull(request.RequestUri);
Assert.Equal("/v2/invoices/zip-requests", request.RequestUri.AbsolutePath);
Assert.Equal(
new[] { "limit=20", "month=3", "page=1", "status=finished", "year=2025" },
request.RequestUri.Query.TrimStart('?').Split('&').OrderBy(parameter => parameter)
);
return Task.FromResult(JsonResponse("{\"page\":1,\"total_pages\":1,\"total_results\":1,\"data\":[{\"id\":\"zip_1\"}]}"));
});

var wrapper = new InvoiceWrapper("test_key", "v2", CreateHttpClient(handler));
var result = await wrapper.ListZipRequestsAsync(new Dictionary<string, object>
{
["year"] = 2025,
["month"] = 3,
["status"] = "finished",
["limit"] = 20,
["page"] = 1
});

Assert.Single(result.Data);
Assert.Equal("zip_1", result.Data[0]["id"]?.ToString());
}

[Fact]
public async Task InvoiceRetrieveZipRequestAsync_UsesZipRequestRoute()
{
var handler = new RecordingHandler((request, cancellationToken) =>
{
Assert.Equal(HttpMethod.Get, request.Method);
Assert.NotNull(request.RequestUri);
Assert.Equal("/v2/invoices/zip-requests/zip_1", request.RequestUri.PathAndQuery);
return Task.FromResult(JsonResponse("{\"id\":\"zip_1\",\"status\":\"finished\"}"));
});

var wrapper = new InvoiceWrapper("test_key", "v2", CreateHttpClient(handler));
var result = await wrapper.RetrieveZipRequestAsync("zip_1");

Assert.Equal("finished", result["status"]?.ToString());
}

[Fact]
public async Task InvoiceDownloadZipRequestAsync_ReturnsSeekableStreamAtPositionZero()
{
var payload = Encoding.UTF8.GetBytes("zip-request-content");
var handler = new RecordingHandler((request, cancellationToken) =>
{
Assert.Equal(HttpMethod.Get, request.Method);
Assert.NotNull(request.RequestUri);
Assert.Equal("/v2/invoices/zip-requests/zip_1/zip", request.RequestUri.PathAndQuery);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(payload)
});
});

var wrapper = new InvoiceWrapper("test_key", "v2", CreateHttpClient(handler));
using var stream = await wrapper.DownloadZipRequestAsync("zip_1");

Assert.Equal(0, stream.Position);
using var reader = new StreamReader(stream, Encoding.UTF8, false, 1024, leaveOpen: true);
var text = await reader.ReadToEndAsync();
Assert.Equal("zip-request-content", text);
}

[Fact]
public async Task RetentionDownloadZipAsync_ReturnsSeekableStreamAtPositionZero()
{
Expand Down
20 changes: 20 additions & 0 deletions Router/InvoiceRouter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,25 @@ public static string PreviewPdf()
{
return "invoices/preview/pdf";
}

public static string ListZipRequests(Dictionary<string, object> query = null)
{
return UriWithQuery("invoices/zip-requests", query);
}

public static string CreateZipRequest()
{
return "invoices/zip-requests";
}

public static string RetrieveZipRequest(string id)
{
return $"invoices/zip-requests/{id}";
}

public static string DownloadZipRequest(string id)
{
return $"invoices/zip-requests/{id}/zip";
}
}
}
4 changes: 4 additions & 0 deletions Wrappers/IInvoiceWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,9 @@ public interface IInvoiceWrapper
Task<Invoice> StampDraft(string id, Dictionary<string, object> options = null, CancellationToken cancellationToken = default);
Task<Invoice> CopyToDraftAsync(string id, CancellationToken cancellationToken = default);
Task<Stream> PreviewPdfAsync(Dictionary<string, object> data, CancellationToken cancellationToken = default);
Task<Dictionary<string, object>> CreateZipRequestAsync(Dictionary<string, object> data, CancellationToken cancellationToken = default);
Task<SearchResult<Dictionary<string, object>>> ListZipRequestsAsync(Dictionary<string, object> query = null, CancellationToken cancellationToken = default);
Task<Dictionary<string, object>> RetrieveZipRequestAsync(string id, CancellationToken cancellationToken = default);
Task<Stream> DownloadZipRequestAsync(string id, CancellationToken cancellationToken = default);
}
}
44 changes: 44 additions & 0 deletions Wrappers/InvoiceWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,5 +188,49 @@ public async Task<Stream> PreviewPdfAsync(Dictionary<string, object> data, Cance
return memory;
}
}

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

public async Task<SearchResult<Dictionary<string, object>>> ListZipRequestsAsync(Dictionary<string, object> query = null, CancellationToken cancellationToken = default)
{
using (var response = await client.GetAsync(Router.ListZipRequests(query), cancellationToken).ConfigureAwait(false))
{
await this.ThrowIfErrorAsync(response, cancellationToken).ConfigureAwait(false);
var resultString = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
return JsonConvert.DeserializeObject<SearchResult<Dictionary<string, object>>>(resultString, this.jsonSettings);
}
}

public async Task<Dictionary<string, object>> RetrieveZipRequestAsync(string id, CancellationToken cancellationToken = default)
{
using (var response = await client.GetAsync(Router.RetrieveZipRequest(id), cancellationToken).ConfigureAwait(false))
{
await this.ThrowIfErrorAsync(response, cancellationToken).ConfigureAwait(false);
var resultString = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
return JsonConvert.DeserializeObject<Dictionary<string, object>>(resultString, this.jsonSettings);
}
}

public async Task<Stream> DownloadZipRequestAsync(string id, CancellationToken cancellationToken = default)
{
using (var response = await client.GetAsync(Router.DownloadZipRequest(id), cancellationToken).ConfigureAwait(false))
{
await this.ThrowIfErrorAsync(response, cancellationToken).ConfigureAwait(false);
var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
var memory = new MemoryStream();
await responseStream.CopyToAsync(memory, 81920, cancellationToken).ConfigureAwait(false);
memory.Position = 0;
return memory;
}
}
}
}
Loading