NUTRISYNCBuilders Hub
🏠 🛠
NUTRISYNC · Docs

Sesión r13 — StartUp Admin: la contabilidad del build

5 de agosto de 2026 · de po93 a po98 · una quinta superficie nueva, 3 ficheros SQL, 49 unitarios nuevos y cuatro errores míos que dejaron guardarraíl.

En una frase: NutriSync tiene ya un sitio propio donde se registra lo que cuesta construirla — con partida doble de verdad, IAS 38 sin atajos y una puerta que no perdona a quien no tiene segundo factor.

1 · Por qué un módulo aparte

La petición original era «revisar y rehacer la página de finanzas». La página hub/finance.html es un libro simple de caja: sirve para el día a día y funciona. Lo que faltaba era otra cosa — contabilidad analítica del build: en qué proyecto y en qué funcionalidad se va el dinero, con asientos equilibrados, presupuestos y cierres.

Mezclarlas habría estropeado las dos. Así que StartUp Admin nace como quinta superficie independiente:

PiezaDónde vive
Sitiorepos/adminsite → Pages nutrisync-adminadmin.nutrisynccollective.com
DatosTablas sa_* en el mismo Supabase
Desplieguetools/Deploy StartUp Admin.command (tests → sellado → wrangler)
Testsnode tools/admin-tests.mjs — 49

Cero dependencias con webdeploy, con los packs de Design o con integrate.py. Un pack de Design no puede pisarlo y su despliegue no puede tumbar la web. Lo único compartido es la puerta.

2 · Las dos puertas (y por qué son dos)

Al vivir en otro dominio, la sesión del hub no viaja hasta aquí: localStorage es por origen. Así que StartUp Admin tiene entrada propia, con el mismo Supabase y las mismas founders:

  1. Cloudflare Access — PIN al correo de founder. Dice quién llega a la puerta.
  2. Sesión admin con MFA — correo → código por email → código de la app de autenticación. Dice quién entra.

Y una diferencia deliberada con el resto del hub. is_admin_mfa() tiene una cláusula de gracia: si una cuenta admin nunca activó MFA, entra igual con aal1. Es una rampa de adopción razonable para las herramientas del hub — pero para la contabilidad significaría que el segundo factor es obligatorio solo para quien ya lo puso. Decisión de Juanjo: cerrarlo aquí y solo aquí.

create or replace function public.is_sa_admin()
returns boolean language sql stable security definer as $$
  select exists (select 1 from admins a where a.user_id = auth.uid())
     and coalesce(auth.jwt()->>'aal','aal1') = 'aal2'
$$;

El hub no se toca, así que ninguna founder pierde acceso a sus herramientas. Para ver quién está lista, la página de MFA del hub tiene ahora una tabla (admin_mfa_readiness()): sin ella nadie lo sabía, porque la RLS de admins es «lee tu propia fila».

3 · Qué hace, pestaña a pestaña

PestañaPara qué
📌 HoyRegistrar un coste (proyecto · feature · fase · origen · importe). El asiento equilibrado se genera solo. Cuatro cifras arriba y el coste diario en barras.
🏗 CosteLa vista que responde a «¿en qué se nos ha ido el dinero?»: ranking por feature con su peso, y desglose por origen (tokens de IA, nube, SaaS, colaboradores, comisiones).
🎯 ProyectosPresupuesto editable y consumo real. Ámbar al 80%, rojo al pasarse con el exceso en euros.
📒 DiarioAsientos del periodo y formulario manual que no deja guardar si no cuadra — y el servidor lo vuelve a rechazar si se intenta por otra vía.
📊 InformesP&L por grupo, balance con comprobación de cuadre e ingresos diferidos por días de servicio prestados (IFRS 15).
🔒 CierreChecklist de tres semáforos y candado por día/mes/trimestre/año. Un periodo cerrado rechaza cualquier asiento con esa fecha.

4 · Las tres reglas que no se negocian

El dinero en enteros

Céntimos (*_minor) en la base de datos, en la librería y en la interfaz. Cero coma flotante en importes. SA.toMinor acepta coma decimal española y redondea al céntimo, no trunca.

Un asiento cuadra o no existe

Validación espejo en tres capas: SA.entryBalance en el navegador, sa_entry_add en el servidor, y un CHECK en la tabla que impide que una línea tenga cargo y abono a la vez.

constraint sa_line_one_side check ((debit_minor > 0) <> (credit_minor > 0))

Nada capitaliza solo

IAS 38: investigación → gasto; desarrollo → capitalization_review. La norma permite capitalizar desarrollo bajo condiciones, pero esa decisión tiene consecuencias fiscales: la firma una persona, no un script. Regla única en SA.treatmentFor y en sa_build_cost_add.

El plan de cuentas, el tratamiento de cada partida y la forma de los informes están construidos con criterio razonable pero no auditado. Antes de usar esto para nada estatutario, que lo revise el asesor.

5 · La tercera puerta en el pie

Un sitio al que nadie sabe llegar no existe. La entrada va donde ya está el resto: pie del marketing, junto a Pitch y Builders, con el mismo sello «2FA SECURED».

El ancla se clona de la de Builders en tiempo de integración en lugar de escribirse a mano — el pie vive dentro del payload JSON del motor y reutilizar la cadena real hereda su escapado exacto. Después, rejilla 2×2 con la tercera ficha ocupando las dos columnas: así mide exactamente lo que las dos de arriba sea cual sea la longitud de la etiqueta, también al traducir.

6 · Cuatro errores, cuatro guardarraíles

6.1 · El href con prefijo pegado

Asumí que las barras del atributo href iban escapadas como \u002F — lo están en las etiquetas de cierre <\u002Fa>, pero dentro del valor de un atributo son barras normales. Mi primer reemplazo no encajó, encajó el segundo, y quedó href="hub/https://admin...".

Lo grave es cómo se manifestó: Cloudflare Pages devuelve 200 con la home para cualquier ruta desconocida. El enlace roto no daba 404 — te dejaba en la portada. Síntoma indistinguible de «los tres enlaces están rotos».

Y mi test daba verde sobre el bug, porque lo escribí buscando el escapado que yo había supuesto. Ahora comprueba el valor del href y que ninguno lleve un esquema anidado.

6.2 · El ORDER BY fantasma

sa_dashboard ordenaba el plan contable por sort_order sin incluirlo en el subselect. Postgres acepta eso al crear la función y solo revienta al ejecutarla: por eso pasó los tres semáforos, que no ejecutan SQL. El banner nsFail hizo su trabajo y nombró la consulta y el fichero.

Guardarraíl nuevo: un test lee el SQL y comprueba que cada ORDER BY apunte a una columna que su subselect proyecta.

6.3 · El build que se creía roto

build-adminsite.mjs comprobaba si el texto cambiaba en vez de si la etiqueta existía. Cuando el sello ya era correcto, la sustitución era un no-op legítimo y el script lo reportaba como «no lo encontré». El caso normal —reconstruir sin tocar nada— fallaba.

6.4 · La palanca equivocada

El QR parecía descolgado de la columna Legal, así que empaqueté las columnas a la derecha. Cerró ese hueco y abrió 223px de vacío en el centro del pie. La causa real era otra: la columna de marca medía 290px, y con space-between cuanto menos ocupa la marca más sobrante hay que repartir. Ensanchada a 400px, el ritmo queda uniforme en 78px — QR incluido — sin agujeros.

AntesEmpaquetando a la derechaEnsanchando la marca
Vacío central3022329
Ritmo entre columnas1155178
Hueco Legal → QR1154578

Lección de método: cuando el síntoma es visual, medir la geometría en el navegador antes de escribir CSS. Las tres opciones se probaron en vivo y se eligió con números, no con intuición.

7 · Los packs de calidad, ahora nueve

PackQué mira
verify-web-pack.pyBloques nuestros, centinelas de Design, gate de render C2
web-tests.mjs (45)Snippets reales de publish/, contrato RPC↔SQL, hrefs del pie, geometría declarada
web-linkcheck.mjs1566 enlaces del árbol servido
admin-tests.mjs (49)Nuevo: lógica contable, gate de sintaxis del HTML servido, gating admin, sello de asset, ORDER BY
jest en appdev (129)Lógica pura de la app
smoke-prod.mjsLas 4 superficies vivas + RPCs públicas

Los de StartUp Admin ejercitan el fichero real que se sirve (assets/sa-lib.js), no una copia: una sola fuente para la página y para los tests.

8 · Qué queda


Ficheros de esta sesión: repos/adminsite/ · backend-sql/2026-08-05-startup-admin.sql · -v2.sql · 2026-08-05-mfa-readiness.sql · tools/admin-tests.mjs · tools/build-adminsite.mjs · tools/Deploy StartUp Admin.command · PUESTA-EN-MARCHA-StartUp-Admin.md · MENSAJE-Founders-StartUp-Admin.md