// // 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( HttpMethod.Get, $"companies/{cid}/emission/invoices/{Uri.EscapeDataString(trackId.Trim())}/status", null, cancellationToken).ConfigureAwait(false); } /// public async Task GetSignedXmlAsync( string documentIdOrEncf, string? companyId = null, string? dgiiEnv = null, bool download = false, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(documentIdOrEncf)) throw new ArgumentException("Document identifier or e-NCF is required.", nameof(documentIdOrEncf)); var cid = ResolveCompanyId(companyId); var queryParams = new List(); if (download) queryParams.Add("download=true"); if (!string.IsNullOrWhiteSpace(dgiiEnv)) queryParams.Add($"dgii_env={Uri.EscapeDataString(dgiiEnv!.Trim().ToLowerInvariant())}"); var queryString = queryParams.Count > 0 ? $"?{string.Join("&", queryParams)}" : string.Empty; var url = BuildUrl($"companies/{cid}/emission/documents/{Uri.EscapeDataString(documentIdOrEncf.Trim())}/signed-xml{queryString}"); using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url); using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); var message = ExtractErrorMessage(content, (int)response.StatusCode); throw new FacturoApiException($"Failed to get signed XML ({(int)response.StatusCode}): {message}", (int)response.StatusCode, content); } return await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false); } /// public async Task RetryEmissionAsync( string documentId, string? companyId = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(documentId)) throw new ArgumentException("Document ID is required.", nameof(documentId)); var cid = ResolveCompanyId(companyId); return await SendJsonAsync( HttpMethod.Post, $"companies/{cid}/emission/documents/{Uri.EscapeDataString(documentId.Trim())}/retry", null, cancellationToken).ConfigureAwait(false); } #endregion public void Dispose() { if (_disposeHttpClient) { _httpClient.Dispose(); } } } #endregion }