// -----------------------------------------------------------------------------
// Recarga.do — librería oficial para C# / .NET
//
// UN SOLO ARCHIVO. Cópielo a su proyecto y úselo. Sin NuGet, sin dependencias:
// solo System.Net.Http y System.Text.Json, que vienen con .NET.
//
// Requiere .NET 6 o superior.
//
// var rd = new RecargaDo(clave, secreto);
//
// // Recarga de monto libre
// var venta = await rd.RecargarAsync(1, "8095551234", 100m, "FACTURA-00123");
//
// // Paquete de datos o factura: primero consultar, luego comprar
// var c = await rd.ConsultarAsync(50, "8094964444");
// var venta2 = await rd.ComprarAsync(50, "8094964444", c.Opciones[0].OpcionId, "FACTURA-00124");
//
// LO ÚNICO QUE DEBE RECORDAR
// El parámetro `referencia` es SU número de factura o venta. Es lo que impide cobrar
// dos veces: si la red se cae y reintenta con la misma referencia, Recarga.do reconoce
// la operación y devuelve el resultado original en lugar de recargar otra vez.
//
// Documentación: https://doc.recarga.do
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
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 RecargaDoApi
{
///
/// Error que impide siquiera intentar la operación: credenciales, permisos,
/// datos mal formados o integración incompleta.
///
/// Un rechazo del proveedor NO llega por aquí: eso es un
/// con estado FAILED, porque el saldo ya volvió y el cajero puede corregir y repetir.
///
public class RecargaDoException : Exception
{
public string Codigo { get; }
public int HttpStatus { get; }
public string? RequestId { get; }
public IReadOnlyDictionary Detalles { get; }
public RecargaDoException(
string mensaje,
string codigo = "ERROR",
int httpStatus = 0,
string? requestId = null,
IReadOnlyDictionary? detalles = null) : base(mensaje)
{
Codigo = codigo;
HttpStatus = httpStatus;
RequestId = requestId;
Detalles = detalles ?? new Dictionary();
}
}
/// Desenlace de una venta. Siempre uno de tres: exitosa, pendiente o fallida.
public class Resultado
{
/// SUCCESS · PENDING · FAILED
public string Estado { get; set; } = "PENDING";
public int? TransactionId { get; set; }
/// Código de autorización, para el recibo del cliente.
public string? AuthCode { get; set; }
/// Referencia interna de Recarga.do.
public string? Xid { get; set; }
///
/// Identificador de la operación con el nombre reference. Es el mismo
/// valor que : otras pasarelas usan uno u otro nombre.
/// Para anular o consultar basta con .
///
public string? Referencia { get; set; }
public decimal? Monto { get; set; }
public string? Identificador { get; set; }
/// Mensaje en español, listo para mostrarle al cajero.
public string Mensaje { get; set; } = "";
/// Código de error estable, si fue rechazada.
public string? ErrorCode { get; set; }
/// Referencia para soporte. Guárdela siempre.
public string? RequestId { get; set; }
/// La clave de idempotencia usada. Guárdela junto a la venta.
public string ClaveIdempotencia { get; set; } = "";
/// true si la API devolvió una respuesta ya emitida antes (reintento seguro).
public bool Repetida { get; set; }
///
/// Datos adicionales. En electricidad prepago trae `token_recarga`: los dígitos
/// que el cliente teclea en su medidor. Imprímalos en el recibo — ese token
/// es el producto que le entrega.
///
public Dictionary Detalles { get; set; } = new();
/// Respuesta completa de la API, por si necesita algún campo suelto.
public JsonElement? Crudo { get; set; }
/// ¿Se entregó? Cobre y entregue el recibo.
public bool Exitosa => Estado == "SUCCESS";
///
/// ¿Quedó en verificación? El importe está retenido, ni cobrado ni devuelto.
/// Dígale al cajero «en verificación» y consulte el estado más tarde.
/// NUNCA lo muestre como fallida: la recarga pudo haberse entregado.
///
public bool Pendiente => Estado == "PENDING";
/// ¿Rechazada? El saldo ya volvió solo. Puede corregir y vender de nuevo.
public bool Fallida => Estado == "FAILED";
/// El token del medidor en electricidad prepago. null en los demás.
public string? TokenRecarga =>
Detalles.TryGetValue("token_recarga", out var v) ? v?.ToString() : null;
}
/// Una opción vendible: un paquete de datos o una factura pendiente.
public class Opcion
{
public string OpcionId { get; set; } = "";
public string Nombre { get; set; } = "";
public string? Descripcion { get; set; }
public decimal Precio { get; set; }
// Paquetes
public string? Vigencia { get; set; }
public string? Datos { get; set; }
public int? Minutos { get; set; }
public int? Sms { get; set; }
// Facturas
public string? Periodo { get; set; }
public string? Vencimiento { get; set; }
public override string ToString() =>
$"{Nombre} — RD${Precio:N2}" + (Vigencia is not null ? $" ({Vigencia})" : "");
}
/// Lo que se le puede vender a un identificador concreto.
public class Consulta
{
/// monto_libre · paquetes · facturas
public string? Flujo { get; set; }
/// Nombre del titular, cuando el proveedor lo devuelve.
public string? Titular { get; set; }
/// ¿Hay que elegir una opción antes de comprar?
public bool RequiereSeleccion { get; set; }
public List Opciones { get; set; } = new();
/// Rango admitido en servicios de monto libre.
public decimal? MontoMinimo { get; set; }
public decimal? MontoMaximo { get; set; }
/// true si el contrato está al día. NO es un error.
public bool SinDeuda { get; set; }
public string Mensaje { get; set; } = "";
/// proveedor_real · simulado
public string? OrigenDatos { get; set; }
public string? RequestId { get; set; }
public bool HayOpciones => Opciones.Count > 0;
public Opcion? Opcion(string opcionId) =>
Opciones.FirstOrDefault(o => o.OpcionId == opcionId);
}
/// Un servicio del catálogo.
public class Servicio
{
public int Id { get; set; }
public string Nombre { get; set; } = "";
public string? Descripcion { get; set; }
public string? Categoria { get; set; }
public string? Tipo { get; set; }
/// monto_libre · paquetes · facturas · seguros
public string Flujo { get; set; } = "monto_libre";
/// Si es true, hay que llamar a ConsultarAsync antes de vender.
public bool RequiereConsulta { get; set; }
/// Explicación en español de cómo se compra este servicio.
public string? ComoComprar { get; set; }
public decimal MontoMinimo { get; set; }
public decimal MontoMaximo { get; set; }
public bool AceptaAnulacion { get; set; }
public override string ToString() => $"[{Id}] {Nombre} ({Flujo})";
}
/// Saldo del comercio.
public class Saldo
{
/// Disponible + retenido. Es el dinero que aún le pertenece.
public decimal SaldoActual { get; set; }
/// Lo que puede gastar ahora mismo.
public decimal SaldoDisponible { get; set; }
/// Comprometido por operaciones en curso: ni cobrado ni gastable.
public decimal SaldoRetenido { get; set; }
public bool SaldoBajo { get; set; }
public string Moneda { get; set; } = "DOP";
}
// -------------------------------------------------------------------------
// CLIENTE
// -------------------------------------------------------------------------
public class RecargaDo : IDisposable
{
public const string Version = "1.0.0";
public const string BaseUrlPorDefecto = "https://api.recarga.do";
/// Credenciales del ambiente de pruebas. No generan cargos reales.
public const string SandboxKey = "rk_sbx_8c4cacb52c6b8f65bf389480ce3f8891";
public const string SandboxSecret = "sk_sbx_a8e4de1dbd5e135a4be191cfd8901f386e541279a6550b1d30af9fbeaa65bbd0";
private readonly HttpClient _http;
private readonly bool _propioHttp;
private readonly string _autorizacion;
private readonly string _baseUrl;
/// Consultas de solo lectura. 15 s por defecto.
public int TimeoutConsultaSegundos { get; set; } = 15;
///
/// Operaciones que mueven dinero. 40 s por defecto.
///
/// No lo baje: la API espera hasta 30 s al proveedor, y un timeout corto
/// convierte ventas buenas en pendientes que hay que ir a verificar.
///
public int TimeoutRecargaSegundos { get; set; } = 40;
///
/// Cliente contra el ambiente de pruebas, con las credenciales públicas.
///
/// No genera cargos ni recarga ningún teléfono real. Para vender de verdad,
/// use el constructor con sus credenciales.
///
public static RecargaDo Pruebas() => new RecargaDo(SandboxKey, SandboxSecret);
public RecargaDo(string apiKey, string apiSecret,
string baseUrl = BaseUrlPorDefecto,
HttpClient? http = null)
{
if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(apiSecret))
throw new RecargaDoException("Faltan las credenciales.", "SIN_CREDENCIALES");
_autorizacion = $"{apiKey}.{apiSecret}";
_baseUrl = baseUrl.TrimEnd('/');
_propioHttp = http is null;
_http = http ?? new HttpClient();
}
// ---------------------------------------------------------------------
// VENDER
// ---------------------------------------------------------------------
///
/// Vende una recarga de monto libre.
///
///
/// SU número de factura, ticket o venta. Esto es lo que impide cobrar dos
/// veces. Si la red se cae y reintenta con la misma referencia, Recarga.do
/// reconoce la operación y devuelve el resultado original. Debe ser única por
/// venta, al menos durante 24 horas. Si la omite se genera una al azar y usted
/// queda responsable de guardarla para poder reintentar.
///
public async Task RecargarAsync(
int servicioId, string identificador, decimal monto,
string? referencia = null, string? terminal = null,
CancellationToken ct = default)
{
var clave = ClaveDe(referencia);
var cuerpo = new Dictionary
{
["service_id"] = servicioId,
["identifier"] = identificador,
["amount"] = decimal.Round(monto, 2),
};
if (!string.IsNullOrEmpty(terminal)) cuerpo["terminal_id"] = terminal;
return await EjecutarVentaAsync(cuerpo, clave, identificador, ct);
}
///
/// PASO 1 de la venta: pregunta qué se le puede vender a este identificador.
///
/// Obligatorio en paquetes de datos y facturas: las opciones dependen del número
/// o del contrato consultado, y el precio lo pone el operador, no usted.
///
public async Task ConsultarAsync(
int servicioId, string identificador, CancellationToken ct = default)
{
var r = await PeticionAsync(HttpMethod.Post, "/v1/consultas",
new Dictionary
{
["service_id"] = servicioId,
["identifier"] = identificador,
}, null, TimeoutConsultaSegundos, ct);
if (!LeerBool(r.Json, "success"))
throw ErrorDe(r);
var d = r.Json.GetProperty("data");
var c = new Consulta
{
Flujo = LeerTexto(d, "flujo"),
Titular = LeerTexto(d, "titular"),
RequiereSeleccion = LeerBool(d, "requiere_seleccion"),
SinDeuda = LeerBool(d, "sin_deuda"),
Mensaje = LeerTexto(d, "mensaje") ?? "",
OrigenDatos = LeerTexto(d, "origen_datos"),
RequestId = r.RequestId,
};
if (d.TryGetProperty("monto", out var m) && m.ValueKind == JsonValueKind.Object)
{
c.MontoMinimo = LeerDecimalNulo(m, "minimo");
c.MontoMaximo = LeerDecimalNulo(m, "maximo");
}
if (d.TryGetProperty("opciones", out var ops) && ops.ValueKind == JsonValueKind.Array)
{
foreach (var o in ops.EnumerateArray())
{
c.Opciones.Add(new Opcion
{
OpcionId = LeerTexto(o, "opcion_id") ?? "",
Nombre = LeerTexto(o, "nombre") ?? "",
Descripcion = LeerTexto(o, "descripcion"),
Precio = LeerDecimalNulo(o, "precio") ?? 0m,
Vigencia = LeerTexto(o, "vigencia"),
Datos = LeerTexto(o, "datos"),
Minutos = LeerEnteroNulo(o, "minutos"),
Sms = LeerEnteroNulo(o, "sms"),
Periodo = LeerTexto(o, "periodo"),
Vencimiento = LeerTexto(o, "vencimiento"),
});
}
}
return c;
}
///
/// PASO 2: compra la opción que eligió el cajero. Para paquetes y facturas.
///
/// No se envía el precio: lo fija el operador o la distribuidora, y la API
/// lo relee antes de cobrar.
///
public async Task ComprarAsync(
int servicioId, string identificador, string opcionId,
string? referencia = null, string? terminal = null,
CancellationToken ct = default)
{
var clave = ClaveDe(referencia);
var cuerpo = new Dictionary
{
["service_id"] = servicioId,
["identifier"] = identificador,
["opcion_id"] = opcionId,
};
if (!string.IsNullOrEmpty(terminal)) cuerpo["terminal_id"] = terminal;
return await EjecutarVentaAsync(cuerpo, clave, identificador, ct);
}
/// Vende cualquier servicio eligiendo solo el flujo correcto.
public Task VenderAsync(
int servicioId, string identificador,
decimal? monto = null, string? opcionId = null,
string? referencia = null, string? terminal = null,
CancellationToken ct = default)
{
if (!string.IsNullOrEmpty(opcionId))
return ComprarAsync(servicioId, identificador, opcionId!, referencia, terminal, ct);
if (monto.HasValue)
return RecargarAsync(servicioId, identificador, monto.Value, referencia, terminal, ct);
throw new RecargaDoException(
"Falta indicar el monto (servicios de monto libre) o el opcionId (paquetes y " +
"facturas). Llame primero a ConsultarAsync para saber cuál corresponde.",
"DATOS_INCOMPLETOS");
}
///
/// Espera a que una venta pendiente se resuelva, consultando su estado.
///
/// Úselo tras un resultado con .
///
public async Task EsperarResultadoAsync(
int transactionId, int segundos = 90, CancellationToken ct = default)
{
var limite = DateTime.UtcNow.AddSeconds(segundos);
var espera = 2000;
while (DateTime.UtcNow < limite)
{
await Task.Delay(espera, ct);
espera = Math.Min(espera + 1000, 8000);
var r = await PeticionAsync(HttpMethod.Get, $"/v1/transacciones/{transactionId}",
null, null, TimeoutConsultaSegundos, ct);
if (!LeerBool(r.Json, "success")) continue;
var d = r.Json.GetProperty("data");
var estado = (LeerTexto(d, "status") ?? "PENDING").ToUpperInvariant();
if (estado is "PENDING" or "PROCESSING") continue;
return new Resultado
{
Estado = estado,
TransactionId = transactionId,
AuthCode = LeerTexto(d, "auth_code"),
Xid = LeerTexto(d, "xid"),
Referencia = LeerTexto(d, "reference"),
Monto = LeerDecimalNulo(d, "amount"),
Identificador = LeerTexto(d, "identifier"),
Mensaje = LeerTexto(d, "message") ?? "",
RequestId = r.RequestId,
Crudo = r.Json,
};
}
return new Resultado
{
Estado = "PENDING",
TransactionId = transactionId,
Mensaje = "Sigue en verificación. Consúltela más tarde; el importe está retenido, no cobrado.",
};
}
/// Anula una venta ya completada y devuelve el importe al saldo.
public async Task AnularAsync(
int transactionId, string? motivo = null, string? referencia = null,
CancellationToken ct = default)
{
var clave = ClaveDe(referencia);
var cuerpo = new Dictionary();
if (!string.IsNullOrEmpty(motivo)) cuerpo["reason"] = motivo;
var r = await PeticionAsync(HttpMethod.Post,
$"/v1/transacciones/{transactionId}/anular", cuerpo,
new Dictionary { ["Idempotency-Key"] = clave },
TimeoutRecargaSegundos, ct);
if (!LeerBool(r.Json, "success")) throw ErrorDe(r);
var d = r.Json.GetProperty("data");
return new Resultado
{
Estado = "REVERSED",
TransactionId = LeerEnteroNulo(d, "transaction_id"),
Monto = LeerDecimalNulo(d, "amount"),
Mensaje = LeerTexto(r.Json, "message") ?? "Operación anulada",
RequestId = r.RequestId,
ClaveIdempotencia = clave,
Crudo = r.Json,
};
}
// ---------------------------------------------------------------------
// CONSULTAS
// ---------------------------------------------------------------------
/// Catálogo de servicios. Cachéelo unas horas: cambia poco.
public async Task> ServiciosAsync(
string? categoria = null, string? buscar = null, CancellationToken ct = default)
{
var qs = new List();
if (!string.IsNullOrEmpty(categoria)) qs.Add("categoria=" + Uri.EscapeDataString(categoria!));
if (!string.IsNullOrEmpty(buscar)) qs.Add("q=" + Uri.EscapeDataString(buscar!));
var ruta = "/v1/servicios" + (qs.Count > 0 ? "?" + string.Join("&", qs) : "");
var r = await PeticionAsync(HttpMethod.Get, ruta, null, null, TimeoutConsultaSegundos, ct);
if (!LeerBool(r.Json, "success")) throw ErrorDe(r);
var lista = new List();
foreach (var s in r.Json.GetProperty("data").EnumerateArray())
{
lista.Add(new Servicio
{
Id = LeerEnteroNulo(s, "id") ?? 0,
Nombre = LeerTexto(s, "nombre") ?? "",
Descripcion = LeerTexto(s, "descripcion"),
Categoria = LeerTexto(s, "categoria"),
Tipo = LeerTexto(s, "tipo"),
Flujo = LeerTexto(s, "flujo") ?? "monto_libre",
RequiereConsulta = LeerBool(s, "requiere_consulta"),
ComoComprar = LeerTexto(s, "como_comprar"),
MontoMinimo = LeerDecimalNulo(s, "monto_minimo") ?? 0m,
MontoMaximo = LeerDecimalNulo(s, "monto_maximo") ?? 0m,
AceptaAnulacion = LeerBool(s, "acepta_anulacion"),
});
}
return lista;
}
/// ¿Este servicio necesita consultar antes de vender?
public async Task RequiereConsultaAsync(int servicioId, CancellationToken ct = default)
{
var s = (await ServiciosAsync(ct: ct)).FirstOrDefault(x => x.Id == servicioId);
return s?.RequiereConsulta ?? false;
}
public async Task SaldoAsync(CancellationToken ct = default)
{
var r = await PeticionAsync(HttpMethod.Get, "/v1/saldo", null, null, TimeoutConsultaSegundos, ct);
if (!LeerBool(r.Json, "success")) throw ErrorDe(r);
var d = r.Json.GetProperty("data");
return new Saldo
{
SaldoActual = LeerDecimalNulo(d, "saldo_actual") ?? 0m,
SaldoDisponible = LeerDecimalNulo(d, "saldo_disponible") ?? 0m,
SaldoRetenido = LeerDecimalNulo(d, "saldo_retenido") ?? 0m,
SaldoBajo = LeerBool(d, "saldo_bajo"),
Moneda = LeerTexto(d, "moneda") ?? "DOP",
};
}
/// Lo que puede gastar ahora mismo.
public async Task DisponibleAsync(CancellationToken ct = default)
=> (await SaldoAsync(ct)).SaldoDisponible;
/// ¿El token opera contra el ambiente de pruebas?
public async Task EnPruebasAsync(CancellationToken ct = default)
{
var r = await PeticionAsync(HttpMethod.Get, "/v1/cuenta", null, null, TimeoutConsultaSegundos, ct);
if (!LeerBool(r.Json, "success")) throw ErrorDe(r);
return LeerBool(r.Json.GetProperty("data"), "sandbox");
}
public async Task TransaccionAsync(int transactionId, CancellationToken ct = default)
{
var r = await PeticionAsync(HttpMethod.Get, $"/v1/transacciones/{transactionId}",
null, null, TimeoutConsultaSegundos, ct);
if (!LeerBool(r.Json, "success")) throw ErrorDe(r);
return r.Json.GetProperty("data");
}
// ---------------------------------------------------------------------
// INTERNO
// ---------------------------------------------------------------------
private static string ClaveDe(string? referencia)
{
var clave = string.IsNullOrWhiteSpace(referencia)
? Guid.NewGuid().ToString()
: referencia!;
if (clave.Length > 128)
throw new RecargaDoException("La referencia no puede pasar de 128 caracteres.",
"REFERENCIA_MUY_LARGA");
return clave;
}
private async Task EjecutarVentaAsync(
Dictionary cuerpo, string clave, string identificador, CancellationToken ct)
{
Respuesta r;
try
{
r = await PeticionAsync(HttpMethod.Post, "/v1/recargas", cuerpo,
new Dictionary { ["Idempotency-Key"] = clave },
TimeoutRecargaSegundos, ct);
}
catch (RecargaDoException e) when (e.Codigo == "SIN_CONEXION")
{
// La red se cortó. NO sabemos si la venta llegó a ejecutarse, así que
// jamás la damos por fallida: eso llevaría a cobrarle dos veces al cliente.
return new Resultado
{
Estado = "PENDING",
ErrorCode = "SIN_CONEXION",
ClaveIdempotencia = clave,
Identificador = identificador,
Mensaje = "No hubo respuesta de Recarga.do. No se sabe si la operación se ejecutó. " +
$"NO vuelva a cobrar: reintente con la misma referencia \"{clave}\", " +
"o consulte el historial de ese número antes de nada.",
};
}
return Interpretar(r, clave, identificador);
}
private Resultado Interpretar(Respuesta r, string clave, string identificador)
{
var res = new Resultado
{
ClaveIdempotencia = clave,
RequestId = r.RequestId,
Repetida = r.Repetida,
Crudo = r.Json,
Identificador = identificador,
};
// 201 — entregada
if (LeerBool(r.Json, "success") && r.Json.TryGetProperty("data", out var d))
{
res.Estado = (LeerTexto(d, "status") ?? "SUCCESS").ToUpperInvariant();
res.TransactionId = LeerEnteroNulo(d, "transaction_id");
res.AuthCode = LeerTexto(d, "auth_code");
res.Xid = LeerTexto(d, "xid");
res.Referencia = LeerTexto(d, "reference");
res.Monto = LeerDecimalNulo(d, "amount");
res.Identificador = LeerTexto(d, "identifier") ?? identificador;
res.Mensaje = LeerTexto(d, "message") ?? LeerTexto(r.Json, "message") ?? "Operación procesada";
if (d.TryGetProperty("details", out var det) && det.ValueKind == JsonValueKind.Object)
{
foreach (var p in det.EnumerateObject())
res.Detalles[p.Name] = TextoDe(p.Value);
}
return res;
}
var error = r.Json.TryGetProperty("error", out var e) ? e : default;
var codigo = LeerTexto(error, "code") ?? "ERROR";
var mensaje = LeerTexto(error, "message") ?? "La operación no se pudo completar.";
var detalles = DiccionarioDe(error, "details");
// Flujo equivocado: se intentó vender un paquete o factura como monto libre.
// Se lanza porque no lo corrige un reintento: la integración está incompleta.
if (detalles.ContainsKey("consultar_opciones_en"))
{
throw new RecargaDoException(
mensaje + " Llame primero a ConsultarAsync y pase el opcionId a ComprarAsync.",
"REQUIERE_CONSULTA", r.Status, r.RequestId, detalles);
}
// 202 — en verificación: el importe queda retenido, ni cobrado ni devuelto.
if (codigo == "TRANSACTION_PENDING" || r.Status == 202)
{
res.Estado = "PENDING";
res.ErrorCode = codigo;
res.Mensaje = mensaje;
if (detalles.TryGetValue("transaction_id", out var tx) && tx is not null
&& int.TryParse(tx.ToString(), out var txi)) res.TransactionId = txi;
return res;
}
// Credenciales o permisos: no es algo que el cajero pueda corregir.
if (codigo is "UNAUTHENTICATED" or "FORBIDDEN")
throw new RecargaDoException(mensaje, codigo, r.Status, r.RequestId, detalles);
// 5xx: no se sabe si llegó a ejecutarse. Se trata como pendiente.
if (r.Status >= 500 && r.Status != 502)
{
res.Estado = "PENDING";
res.ErrorCode = codigo;
res.Mensaje = mensaje + " No se sabe si la operación se ejecutó: verifique antes de repetir.";
return res;
}
// 502 y 422: rechazo determinista. El saldo ya volvió.
res.Estado = "FAILED";
res.ErrorCode = codigo;
res.Mensaje = mensaje;
return res;
}
private record Respuesta(int Status, JsonElement Json, string? RequestId, bool Repetida);
private async Task PeticionAsync(
HttpMethod metodo, string ruta, Dictionary? cuerpo,
Dictionary? headers, int timeoutSegundos, CancellationToken ct)
{
using var msg = new HttpRequestMessage(metodo, _baseUrl + ruta);
msg.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _autorizacion);
msg.Headers.TryAddWithoutValidation("User-Agent", $"recargado-csharp/{Version}");
if (headers is not null)
foreach (var h in headers) msg.Headers.TryAddWithoutValidation(h.Key, h.Value);
if (cuerpo is not null)
msg.Content = new StringContent(JsonSerializer.Serialize(cuerpo),
Encoding.UTF8, "application/json");
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(timeoutSegundos));
HttpResponseMessage resp;
try
{
resp = await _http.SendAsync(msg, cts.Token);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException)
{
if (ct.IsCancellationRequested) throw;
throw new RecargaDoException(
"No hubo respuesta de Recarga.do: " + ex.Message, "SIN_CONEXION");
}
using (resp)
{
var texto = await resp.Content.ReadAsStringAsync(ct);
JsonElement json;
try
{
json = JsonDocument.Parse(string.IsNullOrWhiteSpace(texto) ? "{}" : texto)
.RootElement.Clone();
}
catch (JsonException)
{
throw new RecargaDoException(
"Respuesta ilegible de Recarga.do.", "RESPUESTA_INVALIDA", (int)resp.StatusCode);
}
var requestId = resp.Headers.TryGetValues("X-Request-Id", out var v)
? v.FirstOrDefault() : LeerTexto(json, "request_id");
var repetida = resp.Headers.TryGetValues("Idempotent-Replay", out var rr)
&& rr.FirstOrDefault() == "true";
return new Respuesta((int)resp.StatusCode, json, requestId, repetida);
}
}
private static RecargaDoException ErrorDe(Respuesta r)
{
var error = r.Json.TryGetProperty("error", out var e) ? e : default;
return new RecargaDoException(
LeerTexto(error, "message") ?? "La operación no se pudo completar.",
LeerTexto(error, "code") ?? "ERROR",
r.Status, r.RequestId, DiccionarioDe(error, "details"));
}
// --- lectura tolerante de JSON ---
private static string? LeerTexto(JsonElement e, string prop)
{
if (e.ValueKind != JsonValueKind.Object || !e.TryGetProperty(prop, out var v)) return null;
return TextoDe(v);
}
private static string? TextoDe(JsonElement v) => v.ValueKind switch
{
JsonValueKind.String => v.GetString(),
JsonValueKind.Number => v.ToString(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null or JsonValueKind.Undefined => null,
_ => v.ToString(),
};
private static bool LeerBool(JsonElement e, string prop)
=> e.ValueKind == JsonValueKind.Object
&& e.TryGetProperty(prop, out var v)
&& v.ValueKind == JsonValueKind.True;
private static int? LeerEnteroNulo(JsonElement e, string prop)
{
if (e.ValueKind != JsonValueKind.Object || !e.TryGetProperty(prop, out var v)) return null;
if (v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i)) return i;
if (v.ValueKind == JsonValueKind.String && int.TryParse(v.GetString(), out var s)) return s;
return null;
}
private static decimal? LeerDecimalNulo(JsonElement e, string prop)
{
if (e.ValueKind != JsonValueKind.Object || !e.TryGetProperty(prop, out var v)) return null;
if (v.ValueKind == JsonValueKind.Number && v.TryGetDecimal(out var d)) return d;
if (v.ValueKind == JsonValueKind.String &&
decimal.TryParse(v.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var s)) return s;
return null;
}
private static Dictionary DiccionarioDe(JsonElement e, string prop)
{
var d = new Dictionary();
if (e.ValueKind == JsonValueKind.Object && e.TryGetProperty(prop, out var v)
&& v.ValueKind == JsonValueKind.Object)
{
foreach (var p in v.EnumerateObject()) d[p.Name] = TextoDe(p.Value);
}
return d;
}
public void Dispose()
{
if (_propioHttp) _http.Dispose();
GC.SuppressFinalize(this);
}
}
}