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.
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:
| Pieza | Dónde vive |
|---|---|
| Sitio | repos/adminsite → Pages nutrisync-admin → admin.nutrisynccollective.com |
| Datos | Tablas sa_* en el mismo Supabase |
| Despliegue | tools/Deploy StartUp Admin.command (tests → sellado → wrangler) |
| Tests | node 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.
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:
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».
| Pestaña | Para qué |
|---|---|
| 📌 Hoy | Registrar un coste (proyecto · feature · fase · origen · importe). El asiento equilibrado se genera solo. Cuatro cifras arriba y el coste diario en barras. |
| 🏗 Coste | La 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). |
| 🎯 Proyectos | Presupuesto editable y consumo real. Ámbar al 80%, rojo al pasarse con el exceso en euros. |
| 📒 Diario | Asientos 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. |
| 📊 Informes | P&L por grupo, balance con comprobación de cuadre e ingresos diferidos por días de servicio prestados (IFRS 15). |
| 🔒 Cierre | Checklist de tres semáforos y candado por día/mes/trimestre/año. Un periodo cerrado rechaza cualquier asiento con esa fecha. |
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.
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))
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.
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.
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.
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.
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.
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.
| Antes | Empaquetando a la derecha | Ensanchando la marca | |
|---|---|---|---|
| Vacío central | 30 | 223 | 29 |
| Ritmo entre columnas | 115 | 51 | 78 |
| Hueco Legal → QR | 115 | 45 | 78 |
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.
| Pack | Qué mira |
|---|---|
verify-web-pack.py | Bloques 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.mjs | 1566 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.mjs | Las 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.
juanjosecebrian@icloud.com de la política — pasa la primera puerta y muere en la segunda.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