# -*- coding: utf-8 -*- """ Recarga.do — Librería oficial para Python ========================================= Un solo archivo. Sin dependencias (solo biblioteca estándar). Python 3.6 o superior. INSTALACIÓN COMPLETA: from recargado import RecargaDo USO COMPLETO: rd = RecargaDo('su_api_key', 'su_api_secret') venta = rd.recargar(1, '8095551234', 100, 'FACTURA-00123') if venta.exitosa(): print('Recarga entregada. Código:', venta.auth_code) elif venta.pendiente(): print('En verificación. NO cobre todavía.') else: print('Rechazada:', venta.mensaje) Esta librería se encarga sola de las cosas que, hechas a mano, cuestan dinero: - Genera y reutiliza la clave de idempotencia a partir de SU número de factura, de modo que un reintento nunca recarga dos veces al mismo cliente. - Usa un tiempo de espera de 40 segundos en las recargas (la API espera hasta 30 al proveedor; un timeout corto crea pendientes innecesarios). - Reintenta sola ante cortes de red y errores del servidor, siempre con la misma clave. Nunca reintenta un error de credenciales, porque eso bloquea la cuenta. - Distingue los tres desenlaces posibles —entregada, en verificación, rechazada— en vez de los dos que la gente suele programar. Documentación: https://doc.recarga.do Versión: 1.0.0 """ import json import time import uuid as _uuid import urllib.error import urllib.parse import urllib.request __version__ = '1.0.0' BASE_URL = 'https://api.recarga.do' # Credenciales públicas del ambiente de pruebas. No mueven dinero real. SANDBOX_KEY = 'rk_sbx_8c4cacb52c6b8f65bf389480ce3f8891' SANDBOX_SECRET = 'sk_sbx_a8e4de1dbd5e135a4be191cfd8901f386e541279a6550b1d30af9fbeaa65bbd0' # Desenlaces en los que NO se sabe si la recarga se entregó. El importe sigue # retenido. Nunca se dan por fallidos: eso llevaría a cobrar dos veces. CODIGOS_DESCONOCIDOS = ('INTERNAL_ERROR', 'SERVICE_TIMEOUT', 'SERVICE_UNAVAILABLE') # ----------------------------------------------------------------------------- # EXCEPCIÓN # ----------------------------------------------------------------------------- class RecargaDoError(Exception): """Se lanza cuando algo impide operar: credenciales incorrectas, permisos insuficientes, parámetros mal formados o la API caída. Los rechazos normales de una recarga (número inválido, saldo insuficiente) NO lanzan esta excepción: llegan como un Resultado fallido, porque son desenlaces de negocio que su aplicación debe mostrarle al cajero, no errores de programación. """ def __init__(self, mensaje, codigo='ERROR', http_status=0, request_id=None, detalles=None): super().__init__(mensaje) self.mensaje = mensaje self.codigo = codigo self.http_status = http_status self.request_id = request_id self.detalles = detalles or {} # ----------------------------------------------------------------------------- # RESULTADO DE UNA RECARGA # ----------------------------------------------------------------------------- class Resultado(object): """El desenlace de una recarga. Siempre es exactamente uno de tres: exitosa() → entregada y cobrada. Imprima el recibo. pendiente() → resultado desconocido. El dinero está retenido, NO cobrado. No reintente: consulte el estado. fallida() → rechazada. El saldo ya volvió a su cuenta automáticamente. """ def __init__(self): self.estado = 'PENDING' # SUCCESS | PENDING | FAILED self.transaction_id = None # Identificador en Recarga.do self.auth_code = None # Código de autorización, para el recibo self.xid = None # Referencia interna de la transacción # Identificador de la operación con el nombre `reference`. Es el mismo valor # que `xid`: otras pasarelas usan uno u otro. Para anular basta transaction_id. self.referencia = None # Datos adicionales. En electricidad prepago trae `token_recarga`: los dígitos # que el cliente teclea en su medidor. Imprímalos: ese token es el producto. self.detalles = {} self.monto = None self.identificador = None self.mensaje = '' # En español, listo para mostrar al cajero self.error_code = None # Código estable, si fue rechazada self.request_id = None # Guárdelo siempre: es la referencia de soporte self.clave_idempotencia = None # Guárdela junto a la venta self.repetida = False # True si la API repitió una respuesta ya emitida self.crudo = {} # Respuesta completa de la API def exitosa(self): """¿Se entregó la recarga? Cobre y entregue el recibo.""" return self.estado == 'SUCCESS' def pendiente(self): """¿Quedó en verificación? El importe está retenido, ni cobrado ni devuelto. Dígale al cajero «en verificación» y consulte más tarde.""" return self.estado == 'PENDING' def token_recarga(self): """El token del medidor en electricidad prepago. None en los demás productos.""" return (self.detalles or {}).get('token_recarga') def fallida(self): """¿Fue rechazada? El saldo ya volvió solo. Puede corregir y vender de nuevo.""" return self.estado == 'FAILED' def __repr__(self): return '' % (self.estado, self.transaction_id, self.mensaje) # ----------------------------------------------------------------------------- # CLIENTE # ----------------------------------------------------------------------------- class RecargaDo(object): def __init__(self, api_key, api_secret, base_url=BASE_URL): """ :param api_key: Su clave. En producción, léala de una variable de entorno. :param api_secret: Su secreto. NUNCA lo ponga en el código de una app móvil ni en JavaScript del navegador. """ if not api_key or not api_secret: raise RecargaDoError( 'Faltan las credenciales. Use RecargaDo(api_key, api_secret), ' 'o RecargaDo.pruebas() para el ambiente de pruebas.', 'SIN_CREDENCIALES' ) self.api_key = api_key self.api_secret = api_secret self.base_url = base_url.rstrip('/') # Tiempo de espera para consultas, en segundos self.timeout_consulta = 15 # Tiempo de espera para recargas. 40 s a propósito: la API espera hasta 30 # al proveedor. Bajarlo produce operaciones PENDING que no hacían falta. self.timeout_recarga = 40 # Reintentos automáticos ante fallos de red o del servidor self.max_reintentos = 2 @classmethod def pruebas(cls): """Cliente conectado al ambiente de pruebas, con credenciales públicas. No genera recargas reales ni cobra un peso.""" return cls(SANDBOX_KEY, SANDBOX_SECRET) # ------------------------------------------------------------------------- # LA OPERACIÓN PRINCIPAL # ------------------------------------------------------------------------- def recargar(self, servicio_id, identificador, monto, referencia=None, terminal=None): """Vende una recarga. venta = rd.recargar(1, '8095551234', 100, 'FACTURA-00123') :param servicio_id: Id del servicio, de servicios(). Ej.: 1 = CLARO :param identificador: Teléfono, contrato o NIC, según el servicio :param monto: Monto en pesos, dentro del rango del servicio :param referencia: SU número de factura, ticket o venta. ESTO ES IMPORTANTE: 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. Debe ser única por venta, al menos durante 24 horas. Si la omite, se genera una al azar y usted queda responsable de guardarla (viene en resultado.clave_idempotencia) para poder reintentar. :param terminal: Opcional. Su caja o terminal, ej. 'CAJA-01'. :returns: Resultado — siempre uno de tres: exitosa, pendiente o fallida. """ clave = str(referencia) if referencia not in (None, '') else str(_uuid.uuid4()) if len(clave) > 128: raise RecargaDoError('La referencia no puede pasar de 128 caracteres.', 'REFERENCIA_MUY_LARGA') cuerpo = { 'service_id': int(servicio_id), 'identifier': str(identificador), 'amount': _normalizar_monto(monto), } if terminal: cuerpo['terminal_id'] = str(terminal) try: r = self._peticion('POST', '/v1/recargas', cuerpo, {'Idempotency-Key': clave}, self.timeout_recarga) except RecargaDoError as e: if e.codigo != 'SIN_CONEXION': raise # La red se cortó. NO sabemos si la recarga llegó a ejecutarse, así que # jamás la damos por fallida: eso llevaría a cobrarle dos veces al cliente. res = Resultado() res.estado = 'PENDING' res.error_code = 'SIN_CONEXION' res.clave_idempotencia = clave res.identificador = str(identificador) res.monto = cuerpo['amount'] res.mensaje = ( 'No hubo respuesta de Recarga.do. No se sabe si la recarga se ejecutó. ' 'NO vuelva a cobrar: reintente con la misma referencia "%s", ' 'o consulte el historial de ese número antes de nada.' % clave ) return res return self._interpretar_recarga(r, clave) def consultar(self, servicio_id, identificador): """PASO 1 de la venta: qué se le puede vender a este identificador. Obligatorio en paquetes de datos y facturas: las opciones dependen del número o del contrato, y el precio lo pone el operador, no usted. c = rd.consultar(50, '8094964444') for o in c['opciones']: print(o['nombre'], o['precio']) venta = rd.comprar(50, '8094964444', elegida['opcion_id'], 'FACT-1042') """ r = self._peticion('POST', '/v1/consultas', { 'service_id': int(servicio_id), 'identifier': str(identificador), }, timeout=self.timeout_consulta) j = r['json'] or {} if not j.get('success'): e = j.get('error') or {} raise RecargaDoError(e.get('message', 'No se pudo consultar.'), e.get('code', 'ERROR'), r['status'], r['request_id'], e.get('details') or {}) d = j.get('data') or {} return { 'flujo': d.get('flujo'), 'titular': d.get('titular'), 'requiere_seleccion': bool(d.get('requiere_seleccion')), 'opciones': d.get('opciones') or [], 'monto': d.get('monto'), 'sin_deuda': bool(d.get('sin_deuda')), 'mensaje': d.get('mensaje', ''), 'origen_datos': d.get('origen_datos'), 'request_id': r['request_id'], 'crudo': j, } def comprar(self, servicio_id, identificador, opcion_id, referencia=None, terminal=None): """PASO 2: compra la opción elegida. Para paquetes y facturas. No se envía el precio: lo fija el operador y la API lo relee antes de cobrar. """ clave = str(referencia) if referencia not in (None, '') else str(_uuid.uuid4()) if len(clave) > 128: raise RecargaDoError('La referencia no puede pasar de 128 caracteres.', 'REFERENCIA_MUY_LARGA') cuerpo = { 'service_id': int(servicio_id), 'identifier': str(identificador), 'opcion_id': str(opcion_id), } if terminal: cuerpo['terminal_id'] = str(terminal) try: r = self._peticion('POST', '/v1/recargas', cuerpo, {'Idempotency-Key': clave}, self.timeout_recarga) except RecargaDoError as e: if e.codigo != 'SIN_CONEXION': raise res = Resultado() res.estado = 'PENDING' res.error_code = 'SIN_CONEXION' res.clave_idempotencia = clave res.identificador = str(identificador) res.mensaje = ('No hubo respuesta de Recarga.do. No se sabe si la compra se ejecutó. ' 'NO vuelva a cobrar: reintente con la misma referencia "%s".' % clave) return res return self._interpretar_recarga(r, clave) def vender(self, service_id, identifier, amount=None, opcion_id=None, referencia=None, terminal=None): """Vende cualquier servicio eligiendo solo el flujo correcto.""" if opcion_id: return self.comprar(service_id, identifier, opcion_id, referencia, terminal) if amount is not None: return self.recargar(service_id, identifier, amount, referencia, terminal) raise RecargaDoError( 'Falta `amount` (monto libre) u `opcion_id` (paquetes y facturas). ' 'Llame primero a consultar().', 'DATOS_INCOMPLETOS') def requiere_consulta(self, servicio_id): """¿Este servicio necesita consultar antes de vender? Cachee el catálogo.""" for s in self.servicios(): if int(s['id']) == int(servicio_id): return bool(s.get('requiere_consulta')) return False def esperar_resultado(self, transaction_id, segundos=90): """Espera a que una recarga pendiente se resuelva, consultando su estado. if venta.pendiente(): venta = rd.esperar_resultado(venta.transaction_id) Devuelve en cuanto deja de estar en verificación, o al agotarse el tiempo (y entonces sigue pendiente). """ limite = time.time() + segundos intervalo = 3 while True: t = self.transaccion(transaction_id) estado = str(t.get('status') or 'PENDING').upper() if estado not in ('PENDING', 'PROCESSING') or time.time() >= limite: res = Resultado() res.estado = 'PENDING' if estado == 'PROCESSING' else estado res.transaction_id = int(t.get('transaction_id') or transaction_id) res.auth_code = t.get('auth_code') res.xid = t.get('xid') res.monto = float(t['amount']) if t.get('amount') is not None else None res.identificador = t.get('identifier') res.mensaje = t.get('message') or _mensaje_por_estado(res.estado) res.crudo = t return res time.sleep(intervalo) if intervalo < 10: intervalo += 2 # espaciar para no gastar el límite de peticiones # ------------------------------------------------------------------------- # CONSULTAS # ------------------------------------------------------------------------- def saldo(self): """Cuánto puede gastar ahora mismo. Valide siempre contra 'saldo_disponible', nunca contra 'saldo_actual': el actual incluye dinero ya comprometido en operaciones en curso. """ return self._datos(self._peticion('GET', '/v1/saldo')) def disponible(self): """Atajo: los pesos que puede gastar ahora, como número.""" return float(self.saldo().get('saldo_disponible') or 0) def servicios(self, categoria=None, buscar=None): """Qué puede vender. rd.servicios() # todo el catálogo rd.servicios('Telefonía') # solo una categoría rd.servicios(buscar='claro') # buscar por nombre El catálogo cambia rara vez: consúltelo al arrancar y guárdelo en memoria unas horas. Consultarlo en cada pantalla agota el límite de peticiones. """ return self._datos(self._peticion('GET', '/v1/servicios' + _query({'categoria': categoria, 'q': buscar}))) def categorias(self): """Las categorías disponibles: Telefonía, Electricidad, Agua, etc.""" return self._datos(self._peticion('GET', '/v1/categorias')) def paquetes(self, servicio_id): """Paquetes de datos de un servicio móvil.""" return self._datos(self._peticion('GET', '/v1/servicios/%d/paquetes' % int(servicio_id))) def transaccion(self, transaction_id): """Detalle de una transacción. Solo ve las de su propia empresa.""" return self._datos(self._peticion('GET', '/v1/transacciones/%d' % int(transaction_id))) def transacciones(self, **filtros): """Historial, paginado. rd.transacciones(status='SUCCESS', per_page=100) Filtros: page, per_page (máx. 200), status, date_from, date_to, identifier. """ return self._datos(self._peticion('GET', '/v1/transacciones' + _query(filtros))) def recibo(self, transaction_id): """Recibo en JSON de una transacción entregada.""" return self._datos(self._peticion('GET', '/v1/transacciones/%d/recibo' % int(transaction_id))) def cuenta(self): """Datos de su empresa y, sobre todo, en qué ambiente está operando.""" return self._datos(self._peticion('GET', '/v1/cuenta')) def en_pruebas(self): """¿Estoy en pruebas? True significa que ninguna recarga es real. Compruébelo antes de su primera venta de verdad.""" c = self.cuenta() return bool(c.get('sandbox')) or (c.get('ambiente') and c['ambiente'] != 'production') # ========================================================================= # A PARTIR DE AQUÍ ES MECÁNICA INTERNA. NO HACE FALTA LEERLA PARA INTEGRAR. # ========================================================================= def _interpretar_recarga(self, r, clave): """Convierte la respuesta de POST /v1/recargas en uno de los tres desenlaces.""" res = Resultado() res.clave_idempotencia = clave res.request_id = r['request_id'] res.repetida = r['repetida'] res.crudo = r['json'] j = r['json'] # 201 — entregada if j.get('success') and j.get('data'): d = j['data'] res.estado = str(d.get('status') or 'SUCCESS').upper() res.referencia = d.get('reference') res.detalles = d.get('details') or {} res.transaction_id = int(d['transaction_id']) if d.get('transaction_id') is not None else None res.auth_code = d.get('auth_code') res.xid = d.get('xid') res.monto = float(d['amount']) if d.get('amount') is not None else None res.identificador = d.get('identifier') res.mensaje = d.get('message') or j.get('message') or 'Recarga procesada' return res error = j.get('error') or {} codigo = error.get('code') or 'ERROR' mensaje = error.get('message') or 'La operación no se pudo completar.' det = error.get('details') if isinstance(error.get('details'), dict) else {} # 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 isinstance(det, dict) and det.get('consultar_opciones_en'): raise RecargaDoError( mensaje + ' Llame primero a consultar() y pase el opcion_id a comprar().', 'REQUIERE_CONSULTA', r['status'], r['request_id'], det) # 202 — en verificación: el dinero queda retenido, ni cobrado ni devuelto if codigo == 'TRANSACTION_PENDING' or r['status'] == 202: res.estado = 'PENDING' res.transaction_id = int(det['transaction_id']) if det.get('transaction_id') is not None else None res.xid = det.get('xid') res.error_code = codigo res.mensaje = mensaje return res # Credenciales o permisos. No se ejecutó nada, y no es algo que el cajero # pueda corregir: es configuración rota. if codigo in ('UNAUTHENTICATED', 'FORBIDDEN'): raise RecargaDoError(mensaje, codigo, r['status'], r['request_id'], det) # Desenlaces en los que NO se sabe si la recarga se entregó. if codigo in CODIGOS_DESCONOCIDOS: res.estado = 'PENDING' res.error_code = codigo res.mensaje = ('Recarga.do no pudo confirmar el resultado de esta operación. ' 'No vuelva a cobrar sin consultar antes su estado.') return res # La misma referencia sigue procesándose. Es correcto NO reintentar. if codigo == 'IDEMPOTENT_REQUEST_IN_PROGRESS': res.estado = 'PENDING' res.error_code = codigo res.mensaje = ('La operación anterior con esta misma referencia sigue en curso. ' 'Consulte su estado; no la repita con otra referencia.') return res # Conflicto de idempotencia: esa referencia ya identifica OTRA operación. # Esta no se ejecutó, así que es seguro darla por fallida. if codigo == 'IDEMPOTENCY_CONFLICT': res.estado = 'FAILED' res.error_code = codigo res.mensaje = ('La referencia "%s" ya se usó para una operación distinta. ' 'Cada venta necesita su propia referencia; revise que su ' 'numeración no se repita.' % clave) return res # Fallo del servidor sin código de rechazo reconocible (500, 503, 504). # Un 502 sí se conoce: es el proveedor rechazando, y ese sí es definitivo. if r['status'] >= 500 and r['status'] != 502: res.estado = 'PENDING' res.error_code = codigo res.mensaje = ('Recarga.do respondió con un error (%d) sin confirmar el resultado. ' 'Consulte el estado antes de volver a cobrar.' % r['status']) return res # Todo lo demás —número inválido, monto fuera de rango, saldo insuficiente, # rechazo de la telco— es un desenlace normal de venta que el cajero puede # corregir. Nunca lanza excepción: un punto de venta no debe caerse porque # alguien tecleó un número mal. El saldo, si se reservó, ya volvió solo. res.estado = 'FAILED' res.error_code = codigo res.mensaje = mensaje res.transaction_id = int(det['transaction_id']) if det.get('transaction_id') is not None else None return res def _datos(self, r): """Extrae data de una respuesta correcta, o lanza si vino un error.""" j = r['json'] if j.get('success'): return j.get('data', {}) error = j.get('error') or {} raise RecargaDoError( error.get('message') or 'Error en la consulta.', error.get('code') or 'ERROR', r['status'], r['request_id'], error.get('details') if isinstance(error.get('details'), dict) else {}, ) def _peticion(self, metodo, ruta, cuerpo=None, headers_extra=None, timeout=None): """Ejecuta la petición HTTP, con reintentos seguros. Reintenta solo lo que se puede reintentar sin riesgo: cortes de red, errores 5xx y exceso de peticiones, siempre repitiendo la MISMA clave de idempotencia, de modo que la API reconozca la operación en vez de duplicarla. Nunca reintenta un 401: diez fallos de credenciales bloquean la cuenta 15 minutos, así que un bucle de reintentos se bloquearía solo. """ espera = timeout if timeout is not None else self.timeout_consulta url = self.base_url + ruta headers = { 'Authorization': 'Bearer %s.%s' % (self.api_key, self.api_secret), 'Accept': 'application/json', 'User-Agent': 'recargado-python/%s' % __version__, } headers.update(headers_extra or {}) intento = 0 ultimo_fallo = '' while intento <= self.max_reintentos: if intento > 0: time.sleep(2 ** intento - 1) # 1 s, 3 s intento += 1 r = _enviar(metodo, url, cuerpo, headers, espera) if r['error_red']: ultimo_fallo = r['error_red'] continue # corte de red: reintentar, misma clave # Reintentable: fallo del servidor o exceso de peticiones. El 502 NO entra: # es el proveedor rechazando la operación, y eso es definitivo. if (r['status'] >= 500 and r['status'] != 502) or r['status'] == 429: ultimo_fallo = 'La API respondió %d.' % r['status'] if intento <= self.max_reintentos: continue return r raise RecargaDoError( 'No se pudo contactar con Recarga.do tras %d intentos. %s ' 'Si estaba recargando, NO lo dé por fallido: consulte el historial con ' 'transacciones(identifier=numero) antes de volver a cobrar.' % (intento, ultimo_fallo), 'SIN_CONEXION', 0 ) # ----------------------------------------------------------------------------- # AUXILIARES # ----------------------------------------------------------------------------- def _enviar(metodo, url, cuerpo, headers, timeout): """Una sola llamada HTTP, solo con biblioteca estándar.""" payload = json.dumps(cuerpo).encode('utf-8') if cuerpo is not None else None cabeceras = dict(headers) if payload is not None: cabeceras['Content-Type'] = 'application/json' req = urllib.request.Request(url, data=payload, headers=cabeceras, method=metodo) try: with urllib.request.urlopen(req, timeout=timeout) as resp: texto = resp.read().decode('utf-8') status = resp.getcode() cab = resp.headers except urllib.error.HTTPError as e: # Un 4xx/5xx no es un fallo de red: trae cuerpo JSON con el error real. texto = e.read().decode('utf-8', 'replace') status = e.code cab = e.headers except Exception as e: return {'error_red': str(e) or e.__class__.__name__, 'status': 0, 'json': {}, 'request_id': None, 'repetida': False} try: j = json.loads(texto) except ValueError: return {'error_red': 'La API devolvió una respuesta que no es JSON (HTTP %s).' % status, 'status': status, 'json': {}, 'request_id': None, 'repetida': False} return { 'error_red': None, 'status': status, 'json': j, 'request_id': j.get('request_id') or cab.get('X-Request-Id'), 'repetida': str(cab.get('Idempotent-Replay') or '').lower() == 'true', } def _query(params): limpios = {k: v for k, v in params.items() if v is not None and v != ''} return '?' + urllib.parse.urlencode(limpios) if limpios else '' def _normalizar_monto(monto): try: m = round(float(monto), 2) except (TypeError, ValueError): raise RecargaDoError('El monto debe ser un número. Recibido: %r' % (monto,), 'MONTO_INVALIDO') if m <= 0: raise RecargaDoError('El monto debe ser mayor que cero.', 'MONTO_INVALIDO') return m def _mensaje_por_estado(estado): if estado == 'SUCCESS': return 'Recarga entregada.' if estado == 'FAILED': return 'Recarga rechazada. El saldo fue devuelto.' return 'Operación en verificación.'