endpoint.pe

Cómo automatizar el tipo de cambio diario en tu ERP

Carga el tipo de cambio SUNAT automáticamente: tarea programada, histórico por fecha, feriados sin publicación, reintentos y ejemplos en Node.js y Python.

eEquipo endpoint.pe•10 jul. 20269 min de lectura

En muchas empresas el tipo de cambio se carga así: alguien de contabilidad entra a la página de SUNAT a primera hora, copia los dos valores y los pega en una pantalla del ERP. Si esa persona está de vacaciones, el sistema factura con el tipo de cambio de ayer. Si se equivoca en una milésima, nadie se da cuenta hasta que el registro de ventas no cuadra. Y si un día nadie lo carga, las facturas en dólares salen con tipo de cambio cero.

Automatizarlo no es difícil, pero tiene más detalles de los que parece: a qué hora correr, qué hacer los feriados, cómo guardar el histórico sin pisar valores antiguos, qué pasa si la fuente no responde. En este artículo armamos ese proceso completo, con una tabla de histórico, una tarea programada y ejemplos listos para adaptar en Node.js y en Python.


Qué tiene que hacer el proceso

Antes de escribir código conviene listar lo que esperas del proceso, porque cada punto es una decisión de diseño:

  1. Obtener el valor del día de una fuente confiable, con compra y venta, una vez por día y sin intervención humana.
  2. Guardar un histórico por fecha, porque las facturas se registran con retraso y las notas de crédito, las provisiones y las auditorías necesitan el tipo de cambio de fechas pasadas.
  3. Resolver los días sin publicación: fines de semana y feriados no tienen valor propio y la regla tributaria es usar el último publicado.
  4. Ser idempotente: si el proceso corre dos veces el mismo día, no debe duplicar ni alterar nada.
  5. Reintentar y avisar: si la fuente falla, insiste con pausas; si sigue fallando, que alguien se entere antes de que se emita la primera factura.
  6. Dejar rastro: de dónde salió cada valor y cuándo se cargó, para poder sustentarlo en una fiscalización.

Recuerda qué es lo que estás guardando: el tipo de cambio que publica SUNAT es el promedio ponderado compra y venta que calcula la SBS al cierre del día anterior. Si no tienes claro cuál columna va en cada registro, lee compra o venta: cuál usar en cada operación. Aquí asumimos que el proceso guarda las dos y que la lógica contable decide cuál aplicar.

Diseña la tabla de histórico

Una sola tabla basta. Lo importante son los tipos de dato y la clave única.

ColumnaTipo sugeridoPara qué
fechaDATEFecha a la que corresponde el valor; clave única junto con moneda
monedaCHAR(3)USD; deja la puerta abierta a otras monedas
compraDECIMAL(8,3)Tipo de cambio compra, tres decimales
ventaDECIMAL(8,3)Tipo de cambio venta, tres decimales
fuenteVARCHARsunat_api, manual, etc.
creado_enTIMESTAMPCuándo se cargó
creado_porVARCHARUsuario o nombre del proceso

Tres reglas que evitan problemas después:

  • Nunca uses FLOAT para compra y venta. Los tipos de cambio tienen tres decimales exactos y un FLOAT te devolverá 3.7119999 en algún momento. DECIMAL o NUMERIC, siempre.
  • Clave única en fecha y moneda. Con eso la carga se vuelve un "upsert": si la fila existe y el valor es el mismo, no pasa nada; si no existe, se inserta.
  • Guarda solo las fechas con publicación. No rellenes sábados y domingos copiando el viernes. La regla del último publicado se resuelve al consultar, buscando la fila con la fecha más reciente que no supere la fecha pedida. Así tu tabla refleja la realidad y puedes auditar qué días hubo valor y qué días no.

Si prefieres materializar los días sin publicación para simplificar reportes, agrega una columna es_arrastre en verdadero para esas filas. Pero que sea una decisión explícita, no un efecto colateral.

Feriados y días sin publicación

La SBS calcula el promedio ponderado con las operaciones de los bancos, así que los días en que el sistema financiero no opera no hay valor nuevo. Para efectos tributarios la regla es clara: en una fecha sin publicación se usa el último tipo de cambio publicado. Si emites el lunes después de un feriado largo, aplicas el último valor disponible antes de esa fecha.

Para tu proceso esto significa dos cosas. La primera: la tarea tiene que correr todos los días, no solo de lunes a viernes, porque no quieres mantener a mano un calendario de feriados del Perú (cambia cada año y a veces se decretan feriados con poca anticipación). Si corre y no hay valor nuevo, simplemente termina sin insertar nada y lo deja anotado en el log.

La segunda: toda consulta de tipo de cambio en el ERP pasa por una sola función, algo como tipoCambioVigente(fecha), que devuelve la fila publicada más reciente cuya fecha no supere la pedida. Ningún módulo lee la tabla directamente. Cuando llega una factura emitida un domingo, esa función devuelve el valor correcto sin que nadie tenga que pensarlo.

Tarea programada con cron

El valor del día suele estar disponible temprano en la mañana. Programa la tarea para que corra antes de que el equipo empiece a facturar y agrega una segunda corrida más tarde por si la primera no encontró el valor todavía:

# crontab del servidor del ERP
CRON_TZ=America/Lima
30 6 * * *   /usr/bin/node /srv/erp/jobs/tipo-cambio.js >> /var/log/erp/tipo-cambio.log 2>&1
0 11 * * *   /usr/bin/node /srv/erp/jobs/tipo-cambio.js >> /var/log/erp/tipo-cambio.log 2>&1

Dos cuidados con la hora. Si el servidor está en UTC (lo normal en la nube), un cron a las 06:30 sin zona horaria corre a la 1:30 de la madrugada en Lima, antes de que exista el valor. Define la zona horaria en el crontab o calcula la fecha de Lima dentro del script, nunca con la fecha local del servidor. Y usa un bloqueo (un archivo lock o una fila en la base de datos) para que las dos corridas no se pisen si la primera se demoró.

Si tu ERP corre en Windows, el Programador de tareas cumple el mismo rol. Si corre en contenedores, un CronJob de Kubernetes o el scheduler de tu plataforma. La lógica del script es la misma.

Ejemplo en Node.js y en Python

Ambos ejemplos consultan la API de tipo de cambio de endpoint.pe, que no recibe parámetros y devuelve el valor más reciente publicado (compra, venta y la fecha a la que corresponde), reintentan ante fallos de red y hacen un upsert en PostgreSQL. El token vive en una variable de entorno, nunca en el código; en seguridad de tokens API explicamos por qué.

Si SUNAT todavía no publicó el valor de hoy, la API devuelve el último publicado con su fecha; como el upsert es por fecha, guardarlo otra vez no cambia nada y el log lo deja anotado. Un 404 solo ocurre si no hay ningún valor disponible: no es un error del proceso y, como toda consulta sin resultado, no consume créditos. El detalle está en la documentación.

Node.js

// jobs/tipo-cambio.js (Node 20 o superior)
import pg from 'pg';

const API = 'https://api.endpoint.pe/v1';
const TOKEN = process.env.ENDPOINT_PE_TOKEN;
const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });

function fechaLima() {
  // 'en-CA' produce el formato YYYY-MM-DD
  return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Lima' }).format(new Date());
}

async function obtenerTipoCambio(intentos = 3) {
  for (let i = 1; i <= intentos; i++) {
    try {
      const res = await fetch(`${API}/tipo-cambio`, {
        headers: { Authorization: `Bearer ${TOKEN}` },
        signal: AbortSignal.timeout(10_000),
      });
      if (res.status === 404) return null;                      // sin ningún valor disponible
      if (res.status === 401 || res.status === 402) {
        throw new Error(`No reintentar: HTTP ${res.status}`);  // token o créditos
      }
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return (await res.json()).data;                           // { fecha, moneda, compra, venta, fuente }
    } catch (err) {
      if (i === intentos || err.message.startsWith('No reintentar')) throw err;
      await new Promise(r => setTimeout(r, 2000 * 2 ** (i - 1))); // 2 s, 4 s, 8 s
    }
  }
}

async function guardar(tc) {
  await db.query(
    `INSERT INTO tipo_cambio (fecha, moneda, compra, venta, fuente, creado_por)
     VALUES ($1, $2, $3, $4, 'sunat_api', 'cron')
     ON CONFLICT (fecha, moneda) DO UPDATE
       SET compra = EXCLUDED.compra, venta = EXCLUDED.venta
       WHERE tipo_cambio.compra <> EXCLUDED.compra OR tipo_cambio.venta <> EXCLUDED.venta`,
    [tc.fecha, tc.moneda, tc.compra, tc.venta],
  );
}

const hoy = fechaLima();
const tc = await obtenerTipoCambio();
if (tc) {
  await guardar(tc);
  const estado = tc.fecha === hoy ? 'OK' : `sin valor para hoy; vigente el del ${tc.fecha}`;
  console.log(`${hoy} ${estado} compra=${tc.compra} venta=${tc.venta}`);
} else {
  console.log(`${hoy} sin valor disponible; se reintenta en la siguiente corrida`);
}
await db.end();

Python

# jobs/tipo_cambio.py (Python 3.9 o superior)
import os, time, logging
from datetime import datetime
from decimal import Decimal
from zoneinfo import ZoneInfo
import requests, psycopg

API = "https://api.endpoint.pe/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['ENDPOINT_PE_TOKEN']}"}
log = logging.getLogger("tipo_cambio")

def fecha_lima() -> str:
    return datetime.now(ZoneInfo("America/Lima")).strftime("%Y-%m-%d")

def obtener_tipo_cambio(intentos: int = 3):
    for i in range(1, intentos + 1):
        try:
            r = requests.get(f"{API}/tipo-cambio", headers=HEADERS, timeout=10)
            if r.status_code == 404:
                return None                                  # sin ningún valor disponible
            if r.status_code in (401, 402):
                raise RuntimeError(f"No reintentar: HTTP {r.status_code}")  # token o créditos
            r.raise_for_status()
            return r.json(parse_float=Decimal)["data"]       # evita pasar por float
        except (requests.ConnectionError, requests.Timeout, requests.HTTPError):
            if i == intentos:
                raise
            time.sleep(2 * 2 ** (i - 1))                     # 2 s, 4 s, 8 s

def guardar(tc: dict) -> None:
    with psycopg.connect(os.environ["DATABASE_URL"]) as conn:
        conn.execute(
            """INSERT INTO tipo_cambio (fecha, moneda, compra, venta, fuente, creado_por)
               VALUES (%s, %s, %s, %s, 'sunat_api', 'cron')
               ON CONFLICT (fecha, moneda) DO UPDATE
                 SET compra = EXCLUDED.compra, venta = EXCLUDED.venta
                 WHERE tipo_cambio.compra <> EXCLUDED.compra OR tipo_cambio.venta <> EXCLUDED.venta""",
            (tc["fecha"], tc["moneda"], tc["compra"], tc["venta"]),
        )

if __name__ == "__main__":
    hoy = fecha_lima()
    tc = obtener_tipo_cambio()
    if tc:
        guardar(tc)
        estado = "OK" if tc["fecha"] == hoy else f"sin valor para hoy; vigente el del {tc['fecha']}"
        log.info("%s %s compra=%s venta=%s", hoy, estado, tc["compra"], tc["venta"])
    else:
        log.info("%s sin valor disponible; se reintenta en la siguiente corrida", hoy)

La API solo devuelve el valor vigente, así que el histórico anterior a tu primera carga no sale de ella: cárgalo una vez desde la publicación de SUNAT o desde tu contabilidad y, de ahí en adelante, la tarea diaria mantiene la tabla al día. La carga diaria normal son unas 30 consultas al mes, bien dentro de las 150 gratuitas.

Reintentos, alertas y monitoreo

El proceso de arriba ya distingue los errores que vale la pena reintentar de los que no, y esa distinción es lo más importante del manejo de fallos:

  • Errores de red, timeouts y respuestas 5xx: reintenta con espera creciente. Tres intentos con 2, 4 y 8 segundos cubren casi todo.
  • HTTP 401 o 402: no reintentes. El token es inválido o se acabaron los créditos, y repetir no lo arregla. Avisa de inmediato.
  • HTTP 404: no es un error. Significa que no hay ningún valor disponible todavía; la siguiente corrida lo resuelve.

Si quieres profundizar en estos patrones, manejo de errores y reintentos al consumir APIs los desarrolla con más detalle.

Luego vienen las alertas que no dependen de un error HTTP. La más valiosa es "llevamos más de dos o tres días sin un valor nuevo": fuera de un feriado largo, eso significa que algo falló en silencio. Agrega también validaciones de sentido común antes de guardar: compra y venta mayores que cero, venta mayor o igual que compra, y una variación frente al último valor publicado por debajo de un umbral (por ejemplo, 5 %). Si una validación falla, no bloquees la carga: guárdala y marca el valor para revisión, porque un valor raro puede ser real.

Deja siempre una puerta para la corrección manual con auditoría: una pantalla donde contabilidad pueda registrar o corregir un valor, con fuente en manual y creado_por con el usuario, y sin borrar nunca filas. En el panel de endpoint.pe, en los planes de pago, puedes ver las métricas de uso para confirmar que la tarea está corriendo cada día.

En resumen

  • Guarda el histórico en una tabla con clave única por fecha y moneda, columnas DECIMAL para compra y venta, y la fuente de cada valor.
  • Guarda solo las fechas con publicación y resuelve fines de semana y feriados al consultar, con una función única que devuelve el último valor publicado.
  • Programa la tarea todos los días, en zona horaria de Lima, con una segunda corrida de respaldo y un bloqueo para que no se pisen.
  • Reintenta los errores de red y 5xx con espera creciente; no reintentes 401 ni 402; trata el 404 como día sin publicación.
  • Alerta cuando pasan varios días sin valor nuevo y permite correcciones manuales con auditoría.

Preguntas frecuentes

¿A qué hora debo programar la tarea?

Temprano en la mañana, antes de que tu equipo emita la primera factura, y con una segunda corrida unas horas después por si el valor todavía no estaba disponible. Observa durante unos días a qué hora aparece el valor y ajusta el horario a tu operación.

¿Qué hago con los feriados?

Nada especial. La tarea corre igual, no encuentra valor nuevo y termina. La función que consulta el tipo de cambio devuelve el último publicado, que es exactamente la regla tributaria. No mantengas un calendario de feriados solo para esto.

¿Cuántas consultas gasta el proceso?

Una por día con resultado, unas 30 al mes, dentro de las 150 consultas gratuitas que incluye endpoint.pe sin tarjeta. Las fechas sin publicación no consumen créditos. Un backfill de histórico consume una consulta por cada fecha con valor.

¿Puedo cargar el histórico de años anteriores?

Sí, recorriendo las fechas con el parámetro fecha en un bucle con pausas. Revisa en la documentación desde qué fecha hay datos disponibles antes de lanzar un backfill largo.

Deja de copiar datos a mano. Automatízalo hoy mismo

Crea tu cuenta, copia tu token y empieza hoy con 150 consultas gratis al mes.