MANUAL INTERNO — OBSIDIAN CRM + ERP
Documentación de Obsidian
Arquitectura, modelo de datos y guía funcional del sistema que corre el negocio de MayaCode y JamStudio: de un mensaje de contacto a una venta facturada, con inventario, compras, contabilidad de partida doble y planillas — todo multiempresa.
00 Qué es Obsidian
Obsidian es el sistema interno donde MayaCode administra su operación completa: cada mensaje que llega por el sitio web, WhatsApp, Instagram o referido se convierte en un registro que se puede dar seguimiento hasta que se vuelve una venta facturada — y desde ahí, hasta el asiento contable y la planilla que paga a quien la entregó. No es un producto que se vende — es la herramienta con la que MayaCode se administra a sí misma, y desde su expansión a ERP, también administra a JamStudio, su división de diseño, dentro del mismo panel pero con los datos completamente separados.
El flujo que ordena todo el sistema, de punta a punta:
Alrededor de ese eje viven cuatro pilares que lo sostienen o lo miden: Inventario (qué hay para vender), Compras (cómo se reabastece), Contabilidad (el libro mayor de partida doble donde todo lo anterior termina posteado) y RRHH (quién lo hizo y cómo se le paga).
01 Arquitectura
Dos aplicaciones independientes desplegadas como Cloudflare Workers, compartiendo una sola base de datos D1.
| Framework | vinext | Next.js App Router adaptado a Cloudflare Workers |
| Rutas | app/api/* | Cada endpoint es un route.ts con GET/POST/PATCH/DELETE |
| Base de datos | env.DB | Binding a D1, SQL crudo con marcadores posicionales, sin ORM |
| Migraciones | migrations/ | 49 archivos numerados, aplicados a local y remoto por separado |
| Motores compartidos | admin/_lib/* | accounting.ts, inventory.ts, payroll.ts, purchase-orders.ts, quotes.ts, opportunities.ts, tenant.ts, auth.ts |
| Framework | Vite + React | SPA de una sola página, sin router de URL (estado en memoria) |
| Vistas | src/views/*.tsx | 26 componentes, uno por sección del menú lateral, agrupados en NAV_GROUPS |
| Cliente API | src/api.ts | Wrapper único api() sobre fetch, adjunta la empresa activa |
| Despliegue | Workers Assets | Build estático de Vite servido como assets del Worker |
_lib/auth.ts verifica el JWT Cf-Access-Jwt-Assertion contra el JWKS de Access (RS256, cacheado en Cache API) y extrae el correo real del usuario en cada request.02 Multiempresa
Una sola base de datos, dos negocios que nunca se mezclan — y ahora, un libro contable y un plan de cuentas por cada uno.
Todas las tablas operativas (contactos, cotizaciones, inventario, compras, asientos, planillas...) cargan una columna company_id. Un usuario puede pertenecer a una o varias empresas vía user_companies, y el panel se lo pregunta cada vez que hace falta:
resolveTenantContext() corre al inicio de casi cualquier ruta admin: valida el JWT, resuelve o crea el usuario, y confirma que pertenece a la empresa que pide — de lo contrario responde 403. Si el frontend no manda ninguna empresa y el usuario solo tiene una, la usa por defecto; con dos o más, exige que se especifique.
?company_id=2), no como encabezado. Un encabezado personalizado como X-Company-Id se probó primero, pero Cloudflare Access responde el preflight CORS de estas rutas por su cuenta —nunca llega al Worker— y su configuración de encabezados permitidos no incluye ninguno custom. Cualquier encabezado nuevo entre admin.mayacode.dev y api.mayacode.dev debe evitarse por la misma razón; un parámetro de consulta o el cuerpo de la petición no tienen ese problema. (Aura, que no usa Cloudflare Access, no tiene esta restricción y sí usa un header — ver su documentación.)
Crear una empresa nueva copia automáticamente las etapas de pipeline de una empresa plantilla (por defecto, MayaCode) — sin eso, una empresa recién creada no podría registrar oportunidades. Cada empresa tiene además su propio account_mappings (pilar Contabilidad, ver §11): sin ese mapeo, sus ventas/facturas/planillas no sabrían a qué cuenta del plan contable postear.
03 Contactos y clientes
La puerta de entrada: cualquier canal, un solo lugar donde aterriza.
| channel | enum | web · whatsapp · instagram · facebook · phone · referral · other |
| channel_contact_id | text | identificador del canal (ej. número de WhatsApp); único por empresa+canal |
| status | enum | new read archived |
| converted_client_id | fk | se llena al convertir el contacto en cliente |
Convertir un contacto (POST /contacts/:id/convert) crea el registro en clients y, si se le agregan productos del catálogo en el mismo paso, además crea de una vez la oportunidad y una cotización en borrador con esas líneas — la ruta más corta entre "alguien escribió" y "ya tiene una propuesta de precio". clients (admin/clients) es desde entonces la entidad "cuenta" contra la que se abren nuevas oportunidades y se emiten facturas, independiente de si sigue llegando actividad por el contacto original.
04 Pipeline y oportunidades
Cuatro etapas por defecto, un cierre binario.
Cada empresa tiene sus propias pipeline_stages (nombre, orden, probabilidad por defecto). Las de fábrica: Nuevo · 10% Calificado · 25% Propuesta · 50% Negociación · 75%. Una oportunidad vive en opportunities con status open/won/lost, un assignee_id opcional, y se mueve de etapa por PATCH; POST /opportunities/:id/win y /lose son los únicos puntos de cierre.
05 Cotizaciones
Un borrador con líneas, hasta que alguien dice que sí.
quote_items copia el precio del producto al momento de agregarlo, así que un cambio de tarifa después no altera cotizaciones ya emitidas. Una cotización sent o approved puede rechazarse (/reject, pasa a rejected) y una rejected puede reabrirse a borrador (/reopen) para volver a intentarlo. POST /quotes/:id/confirm-sale es el único punto donde una cotización approved se convierte en venta: crea la fila en sales, marca la oportunidad won, postea el asiento contable de ingreso (ver §11), y genera automáticamente una tarea de entrega por cada línea de la cotización — todo en un mismo batch atómico.
06 Ventas
El registro de lo que ya se cerró — no se crea a mano.
admin/sales es de solo lectura en la práctica: cada fila nace exclusivamente de confirm-sale sobre una cotización aprobada (§05), nunca por creación directa. Una venta es la referencia que enlazan tanto la reportería (§17, por vendedor vía la oportunidad) como la facturación (§07, from-sale/:saleId) y la contabilidad (§11, el asiento de ingreso que la originó).
07 Facturación
Numeración correlativa propia — la certificación fiscal en línea todavía no está conectada.
| status | enum | draft issued void |
| series / number | text / int | correlativo por empresa, asignado al emitir |
| fel_uuid | text | reservado para la certificación SAT/FEL — sin integración en vivo aún |
| fel_certified_at | text | idem, columna lista pero nunca poblada por el sistema hoy |
Una factura puede crearse suelta (POST /invoices) o a partir de una venta ya cerrada (POST /invoices/from-sale/:saleId, copiando sus líneas). POST /invoices/:id/issue le asigna el correlativo y postea el ingreso al libro contable — pero solo si la factura no ya viene de una venta, que ya posteó su propio asiento en confirm-sale; esa comprobación evita contar el mismo ingreso dos veces.
fel_uuid/fel_certified_at están en el schema desde ya para no tener que migrar cuando se conecte, pero ninguna ruta llama a un certificador externo todavía. Es una integración pendiente, no un bug.08 Catálogo
Lo que cada empresa vende — y, desde la expansión a Inventario, si eso se controla en stock o no.
products es el catálogo por empresa: nombre, tipo (unico · por_horas · recurrente), unidad y precio. Desde la migración de Inventario, cada producto tiene un flag de si se controla por stock (tracked) o no (servicios, trabajo por horas): un producto tracked descuenta de stock_levels al venderse y se reabastece vía Compras; uno untracked se cotiza y vende sin tocar inventario en absoluto.
09 Bodegas y stock
Dónde está físicamente cada cosa, y el historial de por qué cambió.
| warehouses | 1 por sede/almacén | por empresa |
| warehouse_locations | N por bodega | pasillo/estante/bin dentro de una bodega |
stock_levels guarda la cantidad actual por producto+ubicación; stock_movements es el ledger de todo lo que la movió (entradas de compra, salidas de venta, ajustes) — nunca se sobreescribe, solo se acumula. stock_transfers (+ POST /:id/complete) mueve cantidades entre bodegas: queda pendiente hasta que alguien confirma que físicamente llegó, momento en que recién se reflejan los movimientos en origen y destino.
10 Proveedores y órdenes de compra
Cómo entra mercadería nueva al inventario.
suppliers es el registro de proveedores por empresa. Una purchase_order (+ purchase_order_items) sigue: draft → POST /:id/send (se marca enviada al proveedor) → POST /:id/receive. Recibir es el paso que de verdad importa: genera los stock_movements de entrada en la bodega indicada y actualiza stock_levels — antes de recibir, la orden no afecta el inventario en absoluto, sin importar cuánto tiempo lleve enviada.
11 Plan de cuentas y asientos
Partida doble real, alimentada automáticamente por el resto del sistema — nadie captura asientos de ventas a mano.
| accounts | plan de cuentas | por empresa; tipo activo/pasivo/capital/ingreso/gasto |
| journal_entries | 1 por movimiento | fecha, descripción, origen (venta/factura/planilla/manual) |
| journal_lines | N por asiento | cargo o crédito a una cuenta; la suma de un asiento siempre balancea a cero |
account_mappings es la pieza que conecta todo lo anterior con el plan de cuentas sin que el código de Comercial/Facturación/RRHH necesite conocer los números de cuenta de cada empresa: cada empresa mapea "propósitos" (ingreso por ventas, IVA por pagar, cuenta por cobrar, pasivo de IGSS, etc.) a sus propias cuentas. El helper postJournalEntry() es el único punto de escritura al libro mayor — lo llaman confirm-sale, invoices/:id/issue, y la aprobación de planillas (§14); no hay una pantalla de "nuevo asiento manual" separada de esos tres orígenes salvo admin/journal-entries directo para correcciones.
12 Reportes financieros
Lo que dice el libro mayor, no lo que dice el pipeline.
GET /reports/financial lee directamente de journal_entries/journal_lines para un rango de fechas — es el reporte de "verdad contable" (tipo estado de resultados/balance), distinto del reporte comercial de §17 que mide actividad por vendedor y no toca la contabilidad en absoluto.
13 Empleados y asistencia
Una capa nueva sobre los usuarios que ya existían — no todo usuario de Obsidian tiene un perfil de empleado.
employee_profiles (llave user_id) extiende a un user con lo que hace falta para pagarle: puesto, salario base, fecha de ingreso. No todo user con acceso al panel tiene un perfil de empleado — alguien externo con acceso de solo lectura, por ejemplo, no necesita uno. attendance registra marcas de entrada/salida por día, base para calcular días trabajados en cada corrida de planilla.
14 Planillas (IGSS/ISR)
El cálculo de retenciones es real y local — lo que no hay es envío automático al portal del IGSS/SAT.
Una payroll_run agrupa un payroll_item por empleado, calculado según payroll_settings (cuotas y tramos vigentes). El motor en _lib/payroll.ts calcula, por planilla:
- IGSS — cuota laboral (retenida al empleado) y cuota patronal (costo adicional de la empresa), como porcentajes configurables del salario.
- ISR — retención por tramos progresivos sobre el salario, según la tabla vigente en
payroll_settings.
Aprobar la planilla (/approve) es el punto donde de verdad importa contablemente: postea vía postJournalEntry() el gasto de planilla y los pasivos correspondientes (IGSS por pagar, ISR por pagar) contra las cuentas de pasivo sembradas para ese propósito — igual patrón que ventas/facturas en §11.
15 Usuarios y roles
La misma persona, con un puesto distinto en cada empresa — y sin relación con si tiene o no perfil de RRHH.
"Rol" aquí no es una tabla nueva: es el campo libre role de user_companies para la empresa activa (Vendedor, Administrativo, Diseñador…). El mismo usuario puede ser Gerencia en MayaCode y Diseñador en JamStudio — el rol vive en la membresía, no en el usuario. Es solo clasificación: no hay control de acceso por rol, cualquier persona autenticada que pertenezca a la empresa ve y edita todo dentro de ella — el modelo de permisos sigue siendo, deliberadamente, plano. Esto es independiente de employee_profiles (§13): un usuario puede tener rol "Vendedor" sin ser un empleado con planilla (ej. un colaborador externo), y viceversa.
16 Tareas, calendario y páginas
Lo que hay que hacer, cuándo, y el contenido interno alrededor.
tasks (pendiente/en progreso/hecha, prioridad, responsable, fecha límite) y events (reunión/entrega/recordatorio/otro, con hora de inicio y fin) comparten el mismo patrón de asignación por assignee_id que oportunidades. Ambos pueden opcionalmente enlazarse a un cliente u oportunidad. admin/pages es una mini-CMS interna para contenido de referencia del equipo (no público) — notas o páginas de conocimiento que no encajan en ningún otro módulo.
17 Reportería comercial
Quién hizo qué, en qué rango de fechas — exportable a Excel.
Complementa al Dashboard (que muestra el estado actual, GET /stats) con un desglose histórico: por cada empleado de la empresa activa, en un rango from/to elegido a mano, cuenta ventas, cotizaciones por estado, tareas hechas/pendientes y oportunidades ganadas/perdidas/abiertas. La actividad sin responsable asignado se agrupa en una fila "Sin asignar" en vez de perderse.
Ventas y cotizaciones no tienen responsable propio — se atribuyen a través de la oportunidad enlazada (opportunities.assignee_id). El botón Exportar a Excel arma el .xlsx enteramente en el navegador con SheetJS, cargado solo al usarlo para no pesar el resto del panel. No confundir con los reportes financieros de §12: este mide actividad comercial, no lee el libro contable.
18 Empresas y branding
El tenant en sí: nombre, slug, color, logo.
admin/companies administra el registro de empresas (§02) y su identidad visual dentro del panel — color de acento y logo por empresa, para que MayaCode y JamStudio se sientan distintas al cambiar de una a otra aunque comparten el mismo código. Es también donde vive account_mappings por empresa (§11) — sin ese paso de configuración, una empresa nueva no puede facturar ni correr planillas, aunque sí puede cotizar y vender.
19 Landing y JamStudio
Dos sitios distintos, el mismo Obsidian detrás.
mayacode.dev (landing corporativo, React+Vite) y jamstudio.mayacode.dev (landing de la división de diseño, HTML/CSS/JS plano sin build) son sitios estáticos independientes que comparten el mismo endpoint de captación. El formulario de JamStudio envía {"site": "jamstudio"}; el de mayacode.dev no manda site en absoluto. El backend traduce eso a un company_id, así que cada lead aparece en Obsidian ya clasificado — nunca mezclado entre negocios.
// app/api/contact/route.ts
const SITE_COMPANY_ID = { mayacode: 1, jamstudio: 2 };
const companyId = SITE_COMPANY_ID[body.site] ?? 1; // sin site → MayaCode por defecto
Ver el portal del ecosistema para cómo estos dos sitios y Aura se relacionan entre sí.
20 Referencia de API
Todas bajo /api/admin/* (salvo /api/contact y /api/health, públicas), protegidas por Cloudflare Access. Convención constante: {success, data} o {success: false, error}. Muestra representativa por módulo — cada recurso sigue además el CRUD estándar (list/create/get/patch/delete) sobre su propia ruta.
/lose — cierre de oportunidad21 Despliegue
Wrangler, D1, y siempre backend + frontend juntos.
# aplicar una migración nueva (local, luego producción)
wrangler d1 execute mayacode-db --local --file=migrations/00XX_x.sql
wrangler d1 execute mayacode-db --remote --file=migrations/00XX_x.sql
# desplegar cada proyecto
cd mayacode-backend && npm run deploy # vinext build + wrangler deploy
cd mayacode-admin && npm run deploy # vite build + wrangler deploy
Los dos Workers se despliegan por separado, pero cuando un cambio toca el contrato entre ambos —como agregar un parámetro que el panel debe enviar— se despliegan en el mismo momento: un backend nuevo esperando algo que el frontend viejo todavía no manda deja al panel sin funcionar hasta el segundo despliegue.
Obsidian · MayaCode — documento vivo, generado a partir del estado real del código. ← Volver al ecosistema