// ----------------------------------------------------------------------------- // 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); } } }