Skip to content
Merged
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
58 changes: 58 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: CI

on:
pull_request:
push:
branches:
- main
workflow_dispatch:

jobs:
build:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"

- name: Restore dependencies
run: dotnet restore

- name: Build
run: dotnet build facturapi-net.sln --configuration Release --no-restore

test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- test_framework: net6.0
runtime_version: 6.0.x
- test_framework: net8.0
runtime_version: 8.0.x

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
10.0.x
${{ matrix.runtime_version }}

- name: Restore dependencies
run: dotnet restore

- name: Build
run: dotnet build facturapi-net.sln --configuration Release --no-restore

- name: Test
run: dotnet test FacturapiTest/FacturapiTest.csproj --framework ${{ matrix.test_framework }} --configuration Release --no-build
10 changes: 9 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,22 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x" # Adjust the .NET version as needed
dotnet-version: |
6.0.x
8.0.x
10.0.x

- name: Restore dependencies
run: dotnet restore

- name: Build
run: dotnet build --configuration Release --no-restore

- name: Test
run: |
dotnet test FacturapiTest/FacturapiTest.csproj --framework net6.0 --configuration Release --no-build
dotnet test FacturapiTest/FacturapiTest.csproj --framework net8.0 --configuration Release --no-build

- name: Pack
run: dotnet pack --configuration Release --no-build --output ./nupkg

Expand Down
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,52 @@ 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.0.0] - 2026-03-30

### Migration Guide

No changes are required if your code:
- Instantiates `FacturapiClient` and calls wrapper methods directly (for example `await client.Invoice.CreateAsync(...)`).
- Uses `var` when storing wrapper properties (for example `var invoices = client.Invoice;`).
- Does not depend on concrete wrapper types in method signatures, fields, or tests.

You need to update your code if you:
- Type wrapper properties as concrete classes (`CustomerWrapper`, `InvoiceWrapper`, etc.).
- Mock or inject concrete wrapper classes.
- Expose concrete wrappers in your own interfaces or public APIs.

Before (v5):
```csharp
CustomerWrapper customers = client.Customer;
```

After (v6):
```csharp
ICustomerWrapper customers = client.Customer;
```

### Breaking

- `IFacturapiClient` now exposes wrapper interfaces instead of concrete wrapper classes:
- `ICustomerWrapper`, `IProductWrapper`, `IInvoiceWrapper`, `IOrganizationWrapper`, `IReceiptWrapper`, `IRetentionWrapper`, `ICatalogWrapper`, `ICartaporteCatalogWrapper`, `IToolWrapper`, `IWebhookWrapper`.
- `FacturapiClient` properties now return those interface types as part of the public contract.
- Dropped `net452` target framework support. The package now targets `netstandard2.0`, `net6.0`, and `net8.0`.

### Added

- New public interfaces for all wrapper surfaces to improve dependency injection and mocking in tests.
- `Invoice.StampDraftAsync` and legacy `StampDraft` now co-exist; `StampDraft` is marked as obsolete.
- Added initial `FacturapiTest` test project with regression coverage for query building and wrapper behavior.
- Added `FacturapiClient.CreateWithCustomHttpClient(...)` for advanced scenarios where consumers need to provide their own `HttpClient` without changing the default constructor.
- Added organization team-management endpoints to `Organization` / `IOrganizationWrapper`: access listing and retrieval, invite send/cancel/respond flows, role listing/templates/operations, role CRUD, and role reassignment for team members.

### Fixed

- `Retention.CancelAsync` is now consistent with the rest of the SDK: supports `CancellationToken`, disposes HTTP responses, and uses shared error handling (`ThrowIfErrorAsync`).
- Query string generation now handles null values and empty query dictionaries safely.
- README examples were corrected to align with the current async API surface and valid C# snippets.
- Internal async calls in wrapper implementations now consistently use `ConfigureAwait(false)`.

## [5.1.0] - 2026-03-12

### Fix
Expand Down
62 changes: 45 additions & 17 deletions FacturapiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,30 @@ namespace Facturapi
{
public sealed class FacturapiClient : IFacturapiClient
{
public CustomerWrapper Customer { get; private set; }
public ProductWrapper Product { get; private set; }
public InvoiceWrapper Invoice { get; private set; }
public OrganizationWrapper Organization { get; private set; }
public ReceiptWrapper Receipt { get; private set; }
public RetentionWrapper Retention { get; private set; }
public CatalogWrapper Catalog { get; private set; }
public CartaporteCatalogWrapper CartaporteCatalog { get; private set; }
public ToolWrapper Tool { get; private set; }
public WebhookWrapper Webhook { get; private set; }
public ICustomerWrapper Customer { get; private set; }
public IProductWrapper Product { get; private set; }
public IInvoiceWrapper Invoice { get; private set; }
public IOrganizationWrapper Organization { get; private set; }
public IReceiptWrapper Receipt { get; private set; }
public IRetentionWrapper Retention { get; private set; }
public ICatalogWrapper Catalog { get; private set; }
public ICartaporteCatalogWrapper CartaporteCatalog { get; private set; }
public IToolWrapper Tool { get; private set; }
public IWebhookWrapper Webhook { get; private set; }
private readonly HttpClient httpClient;
private readonly bool ownsHttpClient;
private bool disposed;

public FacturapiClient(string apiKey, string apiVersion = "v2")
: this(apiKey, apiVersion, CreateDefaultHttpClient(apiKey, apiVersion), ownsHttpClient: true)
{
var apiKeyBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(apiKey + ":"));
this.httpClient = new HttpClient
{
BaseAddress = new Uri($"https://www.facturapi.io/{apiVersion}/")
};
this.httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", apiKeyBase64);
}

private FacturapiClient(string apiKey, string apiVersion, HttpClient httpClient, bool ownsHttpClient)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.ownsHttpClient = ownsHttpClient;
ConfigureHttpClient(this.httpClient, apiKey, apiVersion);

this.Customer = new CustomerWrapper(apiKey, apiVersion, this.httpClient);
this.Product = new ProductWrapper(apiKey, apiVersion, this.httpClient);
Expand All @@ -42,16 +45,41 @@ public FacturapiClient(string apiKey, string apiVersion = "v2")
this.Webhook = new WebhookWrapper(apiKey, apiVersion, this.httpClient);
}

public static FacturapiClient CreateWithCustomHttpClient(string apiKey, HttpClient httpClient, string apiVersion = "v2")
{
if (httpClient == null)
{
throw new ArgumentNullException(nameof(httpClient));
}

return new FacturapiClient(apiKey, apiVersion, httpClient, ownsHttpClient: false);
}

public void Dispose()
{
if (this.disposed)
{
return;
}

this.httpClient?.Dispose();
if (this.ownsHttpClient)
{
this.httpClient.Dispose();
}
this.disposed = true;
GC.SuppressFinalize(this);
}

private static HttpClient CreateDefaultHttpClient(string apiKey, string apiVersion)
{
return new HttpClient();
}

private static void ConfigureHttpClient(HttpClient client, string apiKey, string apiVersion)
{
var apiKeyBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(apiKey + ":"));
client.BaseAddress = new Uri($"https://www.facturapi.io/{apiVersion}/");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", apiKeyBase64);
}
}
}
125 changes: 125 additions & 0 deletions FacturapiTest/ClientCompatibilityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using Facturapi;
using Facturapi.Wrappers;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Xunit;

namespace FacturapiTest
{
public class ClientCompatibilityTests
{
[Fact]
public void Router_ListCustomers_AllowsNullQueryValues()
{
var query = new Dictionary<string, object>
{
["foo"] = null!,
[""] = "ignored"
};

var url = Router.ListCustomers(query);

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

[Fact]
public async Task RetentionCancelAsync_ThrowsFacturapiExceptionWithStatus()
{
var handler = new StubHttpMessageHandler((request, cancellationToken) =>
{
var response = new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("{\"message\":\"bad retention\",\"status\":400}", Encoding.UTF8, "application/json")
};
return Task.FromResult(response);
});

var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://www.facturapi.io/v2/")
};
var wrapper = new RetentionWrapper("test_key", "v2", httpClient);

var exception = await Assert.ThrowsAsync<FacturapiException>(() =>
wrapper.CancelAsync("ret_123", cancellationToken: CancellationToken.None));

Assert.Equal(400, exception.Status);
Assert.Equal("bad retention", exception.Message);
}

[Fact]
public async Task InvoiceStampDraftAsync_AndLegacyMethod_BothWork()
{
var handler = new StubHttpMessageHandler((request, cancellationToken) =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"id\":\"inv_123\"}", Encoding.UTF8, "application/json")
};
return Task.FromResult(response);
});

var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://www.facturapi.io/v2/")
};
var wrapper = new InvoiceWrapper("test_key", "v2", httpClient);

var fromAsync = await wrapper.StampDraftAsync("inv_123");
#pragma warning disable CS0618
var fromLegacy = await wrapper.StampDraft("inv_123");
#pragma warning restore CS0618

Assert.Equal("inv_123", fromAsync.Id);
Assert.Equal("inv_123", fromLegacy.Id);
}

[Fact]
public async Task CreateWithCustomHttpClient_DoesNotDisposeInjectedClient()
{
var handler = new StubHttpMessageHandler((request, cancellationToken) =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"ok\":true}", Encoding.UTF8, "application/json")
};
return Task.FromResult(response);
});

var injectedHttpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://www.facturapi.io/v2/")
};

var client = FacturapiClient.CreateWithCustomHttpClient("test_key", injectedHttpClient, "v2");

var healthy = await client.Tool.HealthCheckAsync();
Assert.True(healthy);

client.Dispose();

var response = await injectedHttpClient.GetAsync("check");
Assert.True(response.IsSuccessStatusCode);
}

private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handler;

public StubHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handler)
{
this.handler = handler;
}

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return this.handler(request, cancellationToken);
}
}
}
}
23 changes: 23 additions & 0 deletions FacturapiTest/FacturapiTest.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>net6.0;net8.0</TargetFrameworks>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="xunit" Version="2.6.6" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\facturapi-net.csproj" />
</ItemGroup>

</Project>
Loading
Loading