//
// Facturo RD - Electronic Billing & Fiscal Emission API Client for .NET (C#)
// Compatible with .NET Standard 2.0+, .NET 6.0, .NET 7.0, .NET 8.0, and .NET 9.0+.
//
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
namespace Facturo.Client
{
#region Constants & Enums
///
/// Standard DGII fiscal environments.
///
public static class DgiiEnvironment
{
public const string TestECF = "testecf";
public const string CertECF = "certecf";
public const string ECF = "ecf";
}
///
/// Standard DGII Electronic Fiscal Document Types (e-CF).
///
public static class EcfTypes
{
public const int FacturaCreditoFiscal = 31;
public const int FacturaConsumo = 32;
public const int NotaDebito = 33;
public const int NotaCredito = 34;
public const int Compras = 41;
public const int GastosMenores = 43;
public const int RegimenesEspeciales = 44;
public const int Gubernamental = 45;
public const int Exportaciones = 46;
public const int PagosAlExterior = 47;
}
#endregion
#region Options & Exceptions
///
/// Configuration options for the Facturo API client.
///
public class FacturoClientOptions
{
///
/// Base URL of the Facturo API (defaults to "https://ecf.facturord.app/").
///
public string BaseUrl { get; set; } = "https://ecf.facturord.app/";
///
/// Integration API Key secret with appropriate permissions (e.g. documents.emit, documents.view).
///
public string ApiKey { get; set; } = string.Empty;
///
/// Optional default company UUID used when not passed explicitly in methods.
///
public string? DefaultCompanyId { get; set; }
///
/// HTTP request timeout. Defaults to 30 seconds.
///
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
}
///
/// Exception thrown when the Facturo API or DGII returns an HTTP error or validation failure.
///
public class FacturoApiException : Exception
{
public int StatusCode { get; }
public string? RawPayload { get; }
public FacturoApiException(string message, int statusCode, string? rawPayload = null, Exception? innerException = null)
: base(message, innerException)
{
StatusCode = statusCode;
RawPayload = rawPayload;
}
}
#endregion
#region Data Transfer Objects (DTOs)
///
/// Sequence range [from_encf, to_encf].
///
public class SequenceRange
{
[JsonPropertyName("from_encf")]
public string FromENCF { get; set; } = string.Empty;
[JsonPropertyName("to_encf")]
public string ToENCF { get; set; } = string.Empty;
public SequenceRange() { }
public SequenceRange(string fromEncf, string toEncf)
{
FromENCF = fromEncf.Trim();
ToENCF = toEncf.Trim();
}
public static SequenceRange Single(string encf) => new SequenceRange(encf, encf);
}
///
/// Cancellation detail grouped by e-CF type.
///
public class CancellationDetail
{
[JsonPropertyName("ecf_type")]
public int EcfType { get; set; }
[JsonPropertyName("line_number")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? LineNumber { get; set; }
[JsonPropertyName("sequence_ranges")]
public List SequenceRanges { get; set; } = new List();
public CancellationDetail() { }
public CancellationDetail(int ecfType, IEnumerable sequenceRanges, int? lineNumber = null)
{
EcfType = ecfType;
SequenceRanges.AddRange(sequenceRanges);
LineNumber = lineNumber;
}
}
///
/// Request payload to cancel sequence ranges via DGII ANECF v1.0.
///
public class CancelRangesRequest
{
[JsonPropertyName("company_id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CompanyId { get; set; }
[JsonPropertyName("dgii_env")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? DgiiEnv { get; set; }
[JsonPropertyName("details")]
public List Details { get; set; } = new List();
}
///
/// DGII response from sequence cancellation submission.
///
public class CancelRangesResponse
{
[JsonPropertyName("rnc")]
public string Rnc { get; set; } = string.Empty;
[JsonPropertyName("code")]
public string Code { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("total_cancelled")]
public long TotalCancelled { get; set; }
[JsonPropertyName("messages")]
public List Messages { get; set; } = new List();
[JsonPropertyName("signed_xml")]
public string? SignedXml { get; set; }
[JsonPropertyName("signed_xml_path")]
public string? SignedXmlPath { get; set; }
}
///
/// Full e-CF emission request payload.
///
public class ECFEmissionRequest
{
[JsonPropertyName("company_id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CompanyId { get; set; }
[JsonPropertyName("dgii_env")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? DgiiEnv { get; set; }
[JsonPropertyName("allow_certification_reset")]
public bool AllowCertificationReset { get; set; }
[JsonPropertyName("ecf")]
public object? Ecf { get; set; }
}
///
/// Full e-CF emission response.
///
public class ECFEmissionResponse
{
[JsonPropertyName("document_id")]
public string? DocumentId { get; set; }
[JsonPropertyName("encf")]
public string? ENCF { get; set; }
[JsonPropertyName("track_id")]
public string? TrackId { get; set; }
[JsonPropertyName("internal_status")]
public string InternalStatus { get; set; } = string.Empty;
[JsonPropertyName("dgii_status")]
public string DgiiStatus { get; set; } = string.Empty;
[JsonPropertyName("message")]
public string? Message { get; set; }
[JsonPropertyName("signed_xml_path")]
public string? SignedXmlPath { get; set; }
[JsonPropertyName("signed_xml")]
public string? SignedXml { get; set; }
[JsonPropertyName("qr_url")]
public string? QrUrl { get; set; }
[JsonPropertyName("security_code")]
public string? SecurityCode { get; set; }
[JsonPropertyName("emission_exact")]
public string? EmissionExact { get; set; }
}
///
/// Response from DGII track ID status query.
///
public class InvoiceTrackingStatusResponse
{
[JsonPropertyName("track_id")]
public string TrackId { get; set; } = string.Empty;
[JsonPropertyName("invoice_number")]
public string? InvoiceNumber { get; set; }
[JsonPropertyName("dgii_status")]
public string DgiiStatus { get; set; } = string.Empty;
[JsonPropertyName("internal_status")]
public string InternalStatus { get; set; } = string.Empty;
[JsonPropertyName("last_status")]
public string LastStatus { get; set; } = string.Empty;
[JsonPropertyName("last_updated_at")]
public string LastUpdatedAt { get; set; } = string.Empty;
}
///
/// Result from retrying a document emission.
///
public class EmissionResult
{
[JsonPropertyName("document_id")]
public string? DocumentId { get; set; }
[JsonPropertyName("document_type_code")]
public string? DocumentTypeCode { get; set; }
[JsonPropertyName("encf")]
public string? ENCF { get; set; }
[JsonPropertyName("track_id")]
public string? TrackId { get; set; }
[JsonPropertyName("internal_status")]
public string? InternalStatus { get; set; }
[JsonPropertyName("dgii_status")]
public string? DgiiStatus { get; set; }
[JsonPropertyName("message")]
public string? Message { get; set; }
[JsonPropertyName("signed_xml_path")]
public string? SignedXmlPath { get; set; }
[JsonPropertyName("signed_xml")]
public string? SignedXml { get; set; }
[JsonPropertyName("qr_url")]
public string? QrUrl { get; set; }
[JsonPropertyName("signature_code")]
public string? SignatureCode { get; set; }
[JsonPropertyName("signature_datetime")]
public string? SignatureDateTime { get; set; }
[JsonPropertyName("created_at")]
public string? CreatedAt { get; set; }
[JsonPropertyName("total_amount")]
public decimal? TotalAmount { get; set; }
}
#endregion
#region Client Interface & Implementation
///
/// Strongly-typed contract for the Facturo RD API client.
///
public interface IFacturoClient
{
///
/// Cancels e-NCF sequence ranges using DGII ANECF v1.0.
///
Task CancelRangesAsync(
IEnumerable details,
string? companyId = null,
string? dgiiEnv = null,
CancellationToken cancellationToken = default);
///
/// Convenience method to void a single e-NCF number.
///
Task CancelSingleSequenceAsync(
int ecfType,
string encf,
string? companyId = null,
string? dgiiEnv = null,
CancellationToken cancellationToken = default);
///
/// Convenience method to void a contiguous sequence range.
///
Task CancelSequenceRangeAsync(
int ecfType,
string fromEncf,
string toEncf,
string? companyId = null,
string? dgiiEnv = null,
CancellationToken cancellationToken = default);
///
/// Submits a rich e-CF structure for fiscal signing and DGII emission.
///
Task EmitRichECFAsync(
ECFEmissionRequest request,
string? companyId = null,
CancellationToken cancellationToken = default);
///
/// Queries DGII track status for a previously submitted document.
///
Task GetTrackingStatusAsync(
string trackId,
string? companyId = null,
CancellationToken cancellationToken = default);
///
/// Retrieves the raw digitally signed XML for an emitted document.
///
Task GetSignedXmlAsync(
string documentIdOrEncf,
string? companyId = null,
string? dgiiEnv = null,
bool download = false,
CancellationToken cancellationToken = default);
///
/// Retries the emission of a pending or failed document.
///
Task RetryEmissionAsync(
string documentId,
string? companyId = null,
CancellationToken cancellationToken = default);
}
///
/// Production-grade HTTP client implementation for Facturo RD.
/// Designed for HttpClientFactory or direct instantiation.
///
public class FacturoClient : IFacturoClient, IDisposable
{
private readonly HttpClient _httpClient;
private readonly bool _disposeHttpClient;
private readonly FacturoClientOptions _options;
private static readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
///
/// Initializes a new instance of using an existing .
/// Recommended for ASP.NET Core applications using IHttpClientFactory.
///
public FacturoClient(HttpClient httpClient, FacturoClientOptions options)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_disposeHttpClient = false;
ConfigureHeaders(_httpClient, _options);
}
///
/// Initializes a new instance of with default base URL ("https://ecf.facturord.app/").
///
public FacturoClient(string apiKey, string? defaultCompanyId = null)
: this("https://ecf.facturord.app/", apiKey, defaultCompanyId)
{
}
///
/// Initializes a new instance of with custom base URL and configuration.
/// Suitable for standalone tools, console utilities, and desktop applications.
///
public FacturoClient(string baseUrl, string apiKey, string? defaultCompanyId = null, TimeSpan? timeout = null)
{
if (string.IsNullOrWhiteSpace(baseUrl)) throw new ArgumentException("Base URL is required.", nameof(baseUrl));
if (string.IsNullOrWhiteSpace(apiKey)) throw new ArgumentException("API Key is required.", nameof(apiKey));
_options = new FacturoClientOptions
{
BaseUrl = baseUrl.TrimEnd('/'),
ApiKey = apiKey.Trim(),
DefaultCompanyId = defaultCompanyId?.Trim(),
Timeout = timeout ?? TimeSpan.FromSeconds(30),
};
_httpClient = new HttpClient { Timeout = _options.Timeout };
_disposeHttpClient = true;
ConfigureHeaders(_httpClient, _options);
}
private static void ConfigureHeaders(HttpClient client, FacturoClientOptions options)
{
if (!client.DefaultRequestHeaders.Contains("X-API-Key"))
{
client.DefaultRequestHeaders.Add("X-API-Key", options.ApiKey);
}
if (!client.DefaultRequestHeaders.Contains("User-Agent"))
{
client.DefaultRequestHeaders.Add("User-Agent", "FacturoNetClient/1.0 (.NET)");
}
client.DefaultRequestHeaders.Accept.Clear();
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}
private string ResolveCompanyId(string? overrideCompanyId)
{
var cid = !string.IsNullOrWhiteSpace(overrideCompanyId) ? overrideCompanyId : _options.DefaultCompanyId;
if (string.IsNullOrWhiteSpace(cid))
{
throw new InvalidOperationException("company_id must be provided in FacturoClientOptions or as a method parameter.");
}
return cid!.Trim();
}
private string BuildUrl(string path)
{
var cleanPath = path.TrimStart('/');
if (!cleanPath.StartsWith("api/v1/", StringComparison.OrdinalIgnoreCase))
{
cleanPath = $"api/v1/{cleanPath}";
}
var baseUrl = _options.BaseUrl.TrimEnd('/');
return $"{baseUrl}/{cleanPath}";
}
private async Task SendJsonAsync(
HttpMethod method,
string path,
TRequest? requestBody,
CancellationToken cancellationToken)
{
var url = BuildUrl(path);
using var httpRequest = new HttpRequestMessage(method, url);
if (requestBody != null)
{
var json = JsonSerializer.Serialize(requestBody, _jsonOptions);
httpRequest.Content = new StringContent(json, Encoding.UTF8, "application/json");
}
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
var contentString = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorMessage = ExtractErrorMessage(contentString, (int)response.StatusCode);
throw new FacturoApiException(
$"Facturo API error ({(int)response.StatusCode}): {errorMessage}",
(int)response.StatusCode,
contentString);
}
if (string.IsNullOrWhiteSpace(contentString))
{
return default!;
}
return JsonSerializer.Deserialize(contentString, _jsonOptions)!;
}
private static string ExtractErrorMessage(string content, int statusCode)
{
try
{
using var doc = JsonDocument.Parse(content);
if (doc.RootElement.TryGetProperty("error", out var errElem))
{
return errElem.GetString() ?? content;
}
if (doc.RootElement.TryGetProperty("message", out var msgElem))
{
return msgElem.GetString() ?? content;
}
}
catch
{
// Fall back to plain text
}
return !string.IsNullOrWhiteSpace(content) ? content : $"HTTP {statusCode}";
}
#region IFacturoClient Implementation
///
public async Task CancelRangesAsync(
IEnumerable details,
string? companyId = null,
string? dgiiEnv = null,
CancellationToken cancellationToken = default)
{
if (details == null) throw new ArgumentNullException(nameof(details));
var cid = ResolveCompanyId(companyId);
var request = new CancelRangesRequest
{
CompanyId = cid,
DgiiEnv = dgiiEnv?.Trim().ToLowerInvariant(),
Details = new List(details),
};
return await SendJsonAsync(
HttpMethod.Post,
$"companies/{cid}/emission/cancel-ranges",
request,
cancellationToken).ConfigureAwait(false);
}
///
public Task CancelSingleSequenceAsync(
int ecfType,
string encf,
string? companyId = null,
string? dgiiEnv = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(encf)) throw new ArgumentException("e-NCF cannot be empty.", nameof(encf));
var detail = new CancellationDetail(ecfType, new[] { SequenceRange.Single(encf) });
return CancelRangesAsync(new[] { detail }, companyId, dgiiEnv, cancellationToken);
}
///
public Task CancelSequenceRangeAsync(
int ecfType,
string fromEncf,
string toEncf,
string? companyId = null,
string? dgiiEnv = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(fromEncf)) throw new ArgumentException("fromEncf cannot be empty.", nameof(fromEncf));
if (string.IsNullOrWhiteSpace(toEncf)) throw new ArgumentException("toEncf cannot be empty.", nameof(toEncf));
var detail = new CancellationDetail(ecfType, new[] { new SequenceRange(fromEncf, toEncf) });
return CancelRangesAsync(new[] { detail }, companyId, dgiiEnv, cancellationToken);
}
///
public async Task EmitRichECFAsync(
ECFEmissionRequest request,
string? companyId = null,
CancellationToken cancellationToken = default)
{
if (request == null) throw new ArgumentNullException(nameof(request));
var cid = ResolveCompanyId(companyId);
return await SendJsonAsync(
HttpMethod.Post,
$"companies/{cid}/emission/ecf",
request,
cancellationToken).ConfigureAwait(false);
}
///
public async Task GetTrackingStatusAsync(
string trackId,
string? companyId = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(trackId)) throw new ArgumentException("TrackId is required.", nameof(trackId));
var cid = ResolveCompanyId(companyId);
return await SendJsonAsync