Arquitectura y stack (Facturación)

Tecnologías

CapaTecnología
AppNext.js (App Router) · TypeScript · Tailwind CSS
Hosting appVercel
DatosFirebase Firestore
AuthFirebase Auth (ID token en cada API route)
ArchivosFirebase Storage (XML firmado + XML respuesta)
BackgroundCloud Functions (Node 22) + Cloud Tasks
HaciendaAPI REST Comprobantes Electrónicos v4.4 + IDP OAuth2 (Keycloak)
FirmaXAdES-EPES (SHA256 + RSA), certificado .p12 del emisor
PDFpdf-lib (server-side, sin headless browser)

Mapa de módulos

lib/facturacion/
├── server/
│   ├── emitir.ts          # emitirComprobante(): orquesta todo
│   ├── calculo.ts          # cálculo de líneas, IVA, normalización de unidades
│   ├── emisorStore.ts      # Firestore (ComprobanteDoc) + Storage (XML)
│   ├── notificacion.ts     # correo durable al cliente (XML+acuse+PDF)
│   └── pdf.ts              # generarFacturaPdf() con pdf-lib
├── xml/
│   └── facturaElectronica.ts  # arma el XML v4.4 (todos los tipos)
├── hacienda/
│   ├── oauth.ts            # getAccessToken() (OAuth2 IDP)
│   └── recepcion.ts        # enviarComprobante() / consultarEstado() / parseEstadoJson()
├── client/
│   └── api.ts              # fcApi: llamadas a las API routes con ID token
└── clave.ts, catalogos.ts, types.ts

API routes en app/api/facturacion/* (emitir, estado, comprobante, comprobante/pdf, comprobante/reenviar, exoneracion, cabys, contribuyente, tipocambio, warmup, seguridad…).

Pipeline de emisión

Modelo durable (Cloud Tasks)

Dos colas garantizan que nada se pierda ante caídas:

  • fe-envio-hacienda — envía el XML firmado a Hacienda y consulta el estado; reintenta si Hacienda está caída. El comprobante queda procesando y se puede reenviar manualmente.
  • fe-correo — envía al cliente el XML, el acuse de Hacienda y el PDF.

Patrón: encolar + fallback + idempotencia. La emisión local responde de inmediato (clave/consecutivo) y la confirmación de Hacienda + el correo ocurren en segundo plano. No se bloquea la venta esperando a Hacienda.

Autenticación con Hacienda (oauth.ts)

OAuth2 contra el IDP (Keycloak) de Hacienda: se obtiene un access_token (password grant / refresh) con las credenciales del API del emisor (sacadas de ATV). Endpoints distintos para sandbox y producción:

recepción sandbox:    https://api-sandbox.comprobanteselectronicos.go.cr/recepcion/v1
recepción producción: https://api.comprobanteselectronicos.go.cr/recepcion/v1

PDF y costos

  • El XML firmado y el XML respuesta se guardan en Storage (emisorStore.saveComprobanteXml).
  • El PDF se genera on-demand por link (app/api/facturacion/comprobante/pdf), no se almacena → ahorra costos.
  • pdf.ts usa pdf-lib (fuentes estándar embebidas, corre en Vercel sin navegador).

Seguridad

  • Cada API route valida el ID token de Firebase (authedFetch en client/api.ts).
  • Escrituras server-authoritative: consecutivos, estados y montos finales los decide el servidor.
  • Candado NC/ND: opcional; requiere una clave interna para emitir notas (el hash/salt nunca sale al cliente). API en app/api/facturacion/seguridad.
  • Certificado .p12 y credenciales del API cifrados server-side; nunca llegan al navegador.

App Check (anti-bot)

  • El cliente inicializa Firebase App Check (reCAPTCHA v3) (lib/firebase/client.ts), condicionado a NEXT_PUBLIC_RECAPTCHA_SITE_KEY. Hace que Firebase rechace requests que no vengan de la app real.
  • El enforcement se activa por producto en la consola (Firestore, Auth, Storage). El código del cliente solo protege si el backend está en Enforce (no en Monitor).
  • Los links del correo NO dependen de App Check: el PDF es una Cloud Function con HMAC (FE_PDF_LINK_SECRET) + Admin SDK, y los archivos de Storage se sirven por URL tokenizada o Admin SDK → App Check no los bloquea. Por eso se puede activar enforcement en Storage sin romper los downloads (recomendado: Monitor → Enforce).

Rate-limit por negocio

  • Los endpoints caros tienen rate-limit por negocio (ownerUid:clientId) — lib/facturacion/server/rateLimit.ts. Conteo por ventana de 1 minuto (el límite es por minuto); al superar el tope, el negocio queda bloqueado 5 minutos (penalidad). Contador en Firestore (rateLimits/{owner}__{client}__{accion}: windowStart, count, override?, blockedUntil?), respuesta 429 + Retry-After.
  • Cada negocio es un balde independiente (los clientes no se afectan entre sí); todas las PCs/locales de un mismo negocio comparten su balde, sizeado holgado.
  • Defaults por minuto: emitir 300 · pdf 200 · contribuyente/exoneracion 120 · reenviar/reenviar-hacienda 60 · export 20. Cubren cadenas de varios locales con margen; frenan loops/abuso (miles/min).
  • Override por cliente: poné el campo override (número) en el doc rateLimits/{owner}__{client}__{accion} para subir el tope de un negocio grande sin redeploy.

Endpoints públicos (sin login) — protegidos

EndpointProtección
tasks/correo, tasks/enviar (target de Cloud Tasks)header X-Tasks-Secret → 403 sin el secreto
callback (Hacienda)token + clave de 50 dígitos; junk → 200 e ignora
descargarPdfComprobante (Function)HMAC FE_PDF_LINK_SECRET → 403 antes de generar el PDF
paddleWebhook (Function)validación de firma de Paddle
tipocambiopúblico pero cacheado (barato)

Un bot sin credenciales no puede disparar trabajo caro: todo rechaza barato (401 token / 403 secreto / 200-ignore). El riesgo real es el abuso autenticado, cubierto por el rate-limit por negocio. Refuerzo de plataforma: Vercel (mitigación DDoS) + Cloud Functions maxInstances: 10.