# Casos de uso "Guest code" - Envío por mail de la composición de saldos

**URL:** <https://bc.finneg.com/t/casos-de-uso-guest-code-envio-por-mail-de-la-composicion-de-saldos/6870>\
**Category:** CORE\
**Tags:** caso-de-uso, guest-code, ventas, instructivos, cómo-se-hace\
**Created:** [1 Junio, 2026 12:51 UTC](https://bc.finneg.com/t/casos-de-uso-guest-code-envio-por-mail-de-la-composicion-de-saldos/6870 "2026-06-01T12:51:06Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

**Author:** ![Paula](https://bc.finneg.com/user_avatar/bc.finneg.com/paula/32/27146_2.png) [@Paula](https://bc.finneg.com/u/Paula)\
**Post date:** [1 Junio, 2026 12:51 UTC](https://bc.finneg.com/t/casos-de-uso-guest-code-envio-por-mail-de-la-composicion-de-saldos/6870/1 "2026-06-01T12:51:06Z")

</div>

# Uso de Guest Code con Bproc de transacción

_Este documento muestra como utilizar la herramienta guest code y un bproc de tipo transacción para enviar un mail al cliente al superar cierto monto de deuda._

## Descripción del caso

Automatizar el envío de una notificación por correo electrónico al cliente cuando el sistema registre una nueva factura de venta, siempre y cuando el monto de dicha factura provoque que el saldo total de su deuda supere el límite de crédito previamente establecido.

## Configuraciones

Para que se envíe el mail al cliente se deben configurar:

**1 - Guest code:** Para realizar el script utilizamos la IA Fini. Se le indica la necesidad junto con el contexto necesario y luego de algunas iteraciones se obtiene el script deseado:

```auto
"""
Guest Code: webhook BProc → facturaVenta (identificación externa) → composicionSaldoCliente (cliente + empresa) → mail.
Modificación: el email del cliente se obtiene desde el endpoint organizacion con el código resuelto en facturaVenta.
"""
from __future__ import annotations

import html
import json
from datetime import datetime
from typing import Any

from finnegans.api import ApiException, obtener_entidad, get_reporte
from finnegans.mail import enviar_email
from finnegans.scripting import HTTPResponse, request
from finnegans.bproc import BProcWebhook

_TABLA_COLUMNAS: tuple[tuple[str, tuple[str, ...], str], ...] = (
   ("FECHA", ("FECHA",), "texto"),
   ("DOCUMENTO", ("DOCUMENTO", "IDENTIFICACIONEXTERNA"), "texto"),
   ("Importe moneda principal", ("IMPORTEMONPPAL",), "importe"),
   ("Importe moneda transacción", ("IMPORTEMONTRAN",), "importe"),
   ("MONEDA", ("MONEDA",), "texto"),
   ("FECHAVTO", ("FECHAVTO", "FECHACASHFLOW"), "texto"),
)
_NUM_COLS = len(_TABLA_COLUMNAS)

def _a_float(val: Any) -> float | None:
   if val is None:
       return None
   if isinstance(val, bool):
       return None
   if isinstance(val, (int, float)):
       return float(val)
   s = str(val).strip().replace(" ", "")
   if not s:
       return None
   if "," in s and "." not in s:
       normalized = s.replace(",", ".")
   elif "," in s and "." in s:
       normalized = s.replace(".", "").replace(",", ".")
   else:
       normalized = s
   try:
       return float(normalized)
   except ValueError:
       return None

def _format_importe_numero(n: float) -> str:
   sign = "-" if n < 0 else ""
   n_abs = abs(float(n))
   s = f"{n_abs:,.4f}"
   whole, frac = s.split(".", 1)
   whole = whole.replace(",", ".")
   return f"{sign}{whole},{frac}"

def _sumatoria_importemonppal(filas: list[dict[str, Any]]) -> float:
   total = 0.0
   for row in filas:
       n = _a_float(_valor_fila(row, ("IMPORTEMONPPAL",)))
       if n is not None:
           total += n
   return total

def _organizacion_desde_factura_venta(fv: dict[str, Any], cliente_codigo: str) -> str:
   for nk in ("OrganizacionCliente", "Cliente", "cliente"):
       val = fv.get(nk)
       if val is None:
           continue
       if isinstance(val, str):
           s = val.strip()
           if s:
               return s
       if isinstance(val, dict):
           for k in ("Nombre", "nombre", "RazonSocial", "razonSocial", "NombreFantasia"):
               x = val.get(k)
               if x is not None and str(x).strip():
                   return str(x).strip()
   return cliente_codigo

def _parece_fila_composicion(row: dict[str, Any]) -> bool:
   u = {str(k).upper() for k in row}
   if "IMPORTEMONTRAN" not in u and "IMPORTEMONTRANS" not in u:
       return False
   if "FECHA" not in u:
       return False
   return "DOCUMENTO" in u or "DOCUMENTOID" in u or "IDENTIFICACIONEXTERNA" in u

def _normalizar_filas_reporte(data: Any, depth: int = 0) -> list[dict[str, Any]]:
   if data is None or depth > 8:
       return []
   if isinstance(data, list):
       candidatos = [x for x in data if isinstance(x, dict)]
       if candidatos and _parece_fila_composicion(candidatos[0]):
           return candidatos
       out: list[dict[str, Any]] = []
       for x in data:
           out.extend(_normalizar_filas_reporte(x, depth + 1))
       return out
   if isinstance(data, dict):
       if _parece_fila_composicion(data):
           return [data]
       for k in (
           "data", "items", "result", "results", "rows",
           "records", "lista", "values", "report", "reportData",
       ):
           inner = data.get(k)
           nested = _normalizar_filas_reporte(inner, depth + 1)
           if nested:
               return nested
       for _k, v in data.items():
           nested = _normalizar_filas_reporte(v, depth + 1)
           if nested:
               return nested
   return []

def _valor_fila(row: dict[str, Any], claves: tuple[str, ...]) -> Any:
   upper = {str(k).upper(): v for k, v in row.items()}
   for c in claves:
       cu = c.upper()
       if cu in upper and upper[cu] is not None:
           return upper[cu]
   return None

def _format_importe(val: Any) -> str:
   if val is None:
       return ""
   if isinstance(val, bool):
       return html.escape(str(val))
   n = _a_float(val)
   if n is not None:
       return _format_importe_numero(n)
   s = str(val).strip()
   return html.escape(s) if s else ""

def _celda_texto(val: Any) -> str:
   if val is None:
       return ""
   return html.escape(str(val).strip())

def _armar_html_tabla(filas: list[dict[str, Any]], organizacion: str, ident_ext: str) -> str:
   th_base = (
       "padding:10px 14px;border:1px solid #c9d1d9;background:#f6f8fa;"
       "font-weight:600;font-size:13px;color:#24292f;font-family:Segoe UI,Roboto,Helvetica,Arial,sans-serif"
   )
   td_base = (
       "padding:8px 14px;border:1px solid #d8dee4;font-size:13px;"
       "font-family:Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1f2328"
   )
   td_num = td_base + ";text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap"

   thead_cells: list[str] = []
   for etiqueta, _claves, tipo in _TABLA_COLUMNAS:
       align = "right" if tipo == "importe" else "left"
       thead_cells.append(
           f'<th style="{th_base};text-align:{align}">{html.escape(etiqueta)}</th>'
       )
   thead = "".join(thead_cells)

   body_rows: list[str] = []
   for idx, row in enumerate(filas):
       row_bg = "#ffffff" if idx % 2 == 0 else "#fafbfc"
       tds: list[str] = []
       for _etiqueta, claves, tipo in _TABLA_COLUMNAS:
           raw = _valor_fila(row, claves)
           if tipo == "importe":
               inner = _format_importe(raw)
               tds.append(f'<td style="{td_num};background:{row_bg}">{inner}</td>')
           else:
               tds.append(
                   f'<td style="{td_base};text-align:left;background:{row_bg}">'
                   f"{_celda_texto(raw)}</td>"
               )
       body_rows.append("<tr>" + "".join(tds) + "</tr>")

   empty_msg = (
       f'<tr><td colspan="{_NUM_COLS}" style="{td_base};text-align:center;'
       f'background:#fff;color:#57606a;">Sin registros</td></tr>'
   )
   tbody = "".join(body_rows) if body_rows else empty_msg

   tfoot_html = ""
   if filas:
       idx_ppal = next(
           i
           for i, (_e, claves, _t) in enumerate(_TABLA_COLUMNAS)
           if claves == ("IMPORTEMONPPAL",)
       )
       colspan_before = idx_ppal
       colspan_after = _NUM_COLS - idx_ppal - 1
       total_ppal = _sumatoria_importemonppal(filas)
       total_str = _format_importe(total_ppal)
       tf_bg = "#eef2f7"
       tf_label_style = (
           f"{td_base};font-weight:700;text-align:right;background:{tf_bg};"
           "border-top:2px solid #c9d1d9;color:#24292f"
       )
       tf_num_style = (
           f"{td_num};font-weight:700;background:{tf_bg};"
           "border-top:2px solid #c9d1d9"
       )
       tf_empty_style = (
           f"{td_base};background:{tf_bg};border-top:2px solid #c9d1d9"
       )
       parts = []
       if colspan_before > 0:
           parts.append(
               f'<td colspan="{colspan_before}" style="{tf_label_style}">'
               "Total moneda principal</td>"
           )
       parts.append(f'<td style="{tf_num_style}">{total_str}</td>')
       if colspan_after > 0:
           parts.append(
               f'<td colspan="{colspan_after}" style="{tf_empty_style}"></td>'
           )
       tfoot_html = f"<tfoot><tr>{''.join(parts)}</tr></tfoot>"

   table_wrap = (
       "border-collapse:collapse;width:100%;max-width:920px;margin:12px 0;"
       "box-shadow:0 1px 3px rgba(31,35,40,0.12);border-radius:6px;overflow:hidden"
   )
   intro = (
       f'<p style="margin:0 0 10px;font-family:Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
       f'font-size:14px;color:#24292f">El cliente <b>{html.escape(organizacion)}</b> '
       f"supera la tolerancia permitida con la carga del documento "
       f"<b>{html.escape(ident_ext)}</b>.</p>"
   )

   return (
       f"{intro}"
       f'<table role="presentation" style="{table_wrap}">'
       f"<thead><tr>{thead}</tr></thead><tbody>{tbody}</tbody>{tfoot_html}</table>"
   )

def main():
   try:
         payload = json.loads(request.get_body())  
      
       bproc = BProcWebhook(payload)
       tolerancia = _a_float(bproc.params.get("Tolerancia"))
       email_raw = bproc.params.get("emailAlerta") #Se obtienen los parámetros configurados en el bproc

       email_alerta = str(email_raw).strip() if email_raw else None

       if tolerancia is None:
           return HTTPResponse(
               400,
               body=json.dumps(
                   {"error": "Falta el parámetro Tolerancia en el body del BProc"},
                   ensure_ascii=False,
               ),
           )
       # Obtengo la identificacion externa obtenida del Bproc
       ident_ext = bproc.object.code # Obtiene la Identificación externa de la transacción que activó el webhook
      
       if not ident_ext:
           return HTTPResponse(
               400,
               body=json.dumps(
                   {"error": "No se pudo resolver identificacionExterna del webhook"},
                   ensure_ascii=False,
               ),
           )
       # Hago un GET de facturaVenta con la identificacion externa
       fv = obtener_entidad("facturaVenta", ident_ext) # Realiza un GET de la api de facturaVenta. De la respuesta se obtienen los datos del cliente y de la empresa

       cliente = fv.get("Cliente")
       empresa = fv.get("EmpresaCodigo")

       if not cliente or not empresa:
           return HTTPResponse(
               400,
               body=json.dumps(
                   {
                       "error": "facturaVenta sin cliente y/o empresa resolvibles",
                       "cliente": cliente,
                       "empresa": empresa,
                   },
                   ensure_ascii=False,
               ),
           )

       # ── NUEVO: email del cliente vía endpoint organizacion ───────────────
       email_cliente = None
       if cliente:
           try:
               org = obtener_entidad("organizacion", cliente) # Realiza un GET de la api de organización para obtener el mail del cliente.

               email_cliente = org.get("Email")
           except Exception:
               return HTTPResponse(
                   400,
                   body=json.dumps(
                       {"error": "No se pudo resolver email del cliente desde organizacion"},
                       ensure_ascii=False,
                   ),
               )

       # Lista de destinatarios: siempre email_cliente + email_alerta si tiene email configurado
       destinatarios: list[str] = [email_cliente]
       if email_alerta and email_alerta != email_cliente:
           destinatarios.append(email_alerta)
       # ────────────────────────────────────────────────────────────────────
       raw_composicion = get_reporte("composicionSaldoCliente", {"PARAMWEBREPORT_organizacion": cliente, "PARAMWEBREPORT_fecha": datetime.now().strftime("%Y-%m-%d")}) #Realiza un GET de una api de tipo View/informe. Recibe como parámetro un mapping con los parámetros del informe

       filas = _normalizar_filas_reporte(raw_composicion) #Parseo del response del informe de composicion de saldos

       sum_pp = _sumatoria_importemonppal(filas)
       debe_enviar_mail = sum_pp > tolerancia # Calculo de la sumatoria de importes y comparacion del saldo respecto a la Tolerancia

       organizacion = " ".join(_organizacion_desde_factura_venta(fv, cliente).split())
       asunto = f"Seguimiento de deuda: {organizacion}"
       cuerpo_html = _armar_html_tabla(filas, organizacion, ident_ext) # Función que convierte en una tabla HTML la respuesta parseada de la composición para el armado del cuerpo del mail.

       if debe_enviar_mail:
           enviar_email(asunto, cuerpo_html, destinatarios, html=True) #Función interna que se encarga del envío de mails.

       return HTTPResponse(
           200,
           body=json.dumps(
               {
                   "status": "success",
                   "identificacionExterna": ident_ext,
                   "cliente": cliente,
                   "organizacion": organizacion,
                   "empresa": empresa,
                   "filas": len(filas),
                   "tolerancia": tolerancia,
                   "sumatoriaImportemonPpal": sum_pp,
                   "emailAlerta": email_alerta,
                   "emailCliente": email_cliente,
                   "emailEnviado": debe_enviar_mail,
                   "destinatarios": destinatarios if debe_enviar_mail else [],
               },
               ensure_ascii=False,
           ),
       ) # si todo se ejecutó correctamente, se devuelve un status response success
   except ApiException as e:
       return HTTPResponse(
           502,
           body=json.dumps({"status": "error", "error": str(e)}, ensure_ascii=False),
       )
   except Exception as e:
       return HTTPResponse(
           500,
           body=json.dumps({"status": "error", "error": str(e)}, ensure_ascii=False),
       ) # En caso contrario, retorna la excepción capturada.

```

**2 - Bproc:** En este caso se utilizó un bproc de tipo transacción y modo webhook  
Se configuró para que se ejecute el bproc cuando se cree o actualice una factura

 ![image](https://bc.finneg.com/uploads/default/original/3X/5/d/5d7e4a13d464a2364e0bea1f9f0e67ae6fc81daa.png)  
 ![image](https://bc.finneg.com/uploads/default/original/3X/7/8/782ebd60fae9e0977e59662f579c1d4c29f3c8eb.png)

En los parámetros se indica el límite de la deuda y un correo usado como copia interna cuando se envía el aviso de la deuda.

 ![image](https://bc.finneg.com/uploads/default/original/3X/7/0/708ab6dfa7d97f7a7297edebcd8145df6f13e5ab.png)

En el campo URL se pega la obtenida en el guest code

## Resultado

Al guardar una nueva factura para un cliente que supera la tolerancia establecida, se envía un correo electrónico al cliente y una copia al correo indicado en el bproc

 ![image](https://bc.finneg.com/uploads/default/original/3X/c/5/c58907bac7336a39d91fa15e18111f823bb2f5f0.png)
