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.

Paneladmin.mayacode.dev
APIapi.mayacode.dev
Base de datosCloudflare D1 (SQLite)
AutenticaciónCloudflare Access

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:

Contacto→ Cliente→ Oportunidad→ Cotización→ Venta→ Factura→ Asiento contable

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.

mayacode-backendapi.mayacode.dev
FrameworkvinextNext.js App Router adaptado a Cloudflare Workers
Rutasapp/api/*Cada endpoint es un route.ts con GET/POST/PATCH/DELETE
Base de datosenv.DBBinding a D1, SQL crudo con marcadores posicionales, sin ORM
Migracionesmigrations/49 archivos numerados, aplicados a local y remoto por separado
Motores compartidosadmin/_lib/*accounting.ts, inventory.ts, payroll.ts, purchase-orders.ts, quotes.ts, opportunities.ts, tenant.ts, auth.ts
mayacode-adminadmin.mayacode.dev
FrameworkVite + ReactSPA de una sola página, sin router de URL (estado en memoria)
Vistassrc/views/*.tsx26 componentes, uno por sección del menú lateral, agrupados en NAV_GROUPS
Cliente APIsrc/api.tsWrapper único api() sobre fetch, adjunta la empresa activa
DespliegueWorkers AssetsBuild estático de Vite servido como assets del Worker
Autenticación: Cloudflare Access protege ambos dominios bajo una sola Access Application. Eso basta para bloquear la entrada, pero el backend necesitaba saber quién hace cada petición — algo que Access por sí solo no expone a la aplicación. _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:

MayaCode — id 1 JamStudio — id 2

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.

Detalle no obvio: la empresa activa viaja como parámetro de consulta (?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.

contactsmensajes entrantes
channelenumweb · whatsapp · instagram · facebook · phone · referral · other
channel_contact_idtextidentificador del canal (ej. número de WhatsApp); único por empresa+canal
statusenumnew read archived
converted_client_idfkse 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í.

draft→ sent→ approved→ sold

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.

invoices+ invoice_items
statusenumdraft issued void
series / numbertext / intcorrelativo por empresa, asignado al emitir
fel_uuidtextreservado para la certificación SAT/FEL — sin integración en vivo aún
fel_certified_attextidem, 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.

Qué falta de verdad: la numeración y el asiento contable son 100% funcionales hoy. Lo que no existe es la llamada al certificador FEL de la SAT — 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.

09 Bodegas y stock

Dónde está físicamente cada cosa, y el historial de por qué cambió.

warehouses → warehouse_locationsjerarquía física
warehouses1 por sede/almacénpor empresa
warehouse_locationsN por bodegapasillo/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 → journal_entries → journal_lineslibro mayor
accountsplan de cuentaspor empresa; tipo activo/pasivo/capital/ingreso/gasto
journal_entries1 por movimientofecha, descripción, origen (venta/factura/planilla/manual)
journal_linesN por asientocargo 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.

draft→ calculada→ POST /:id/approve

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.

Qué falta de verdad: el cálculo de cuotas/retenciones corre localmente y es correcto según la configuración cargada; lo que no existe es una integración que presente esa planilla ante el portal del IGSS o la SAT — eso sigue siendo un paso manual fuera del sistema, igual que la certificación FEL de §07.

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.

POST/contacts/:id/convertcontacto → cliente (+ oportunidad + cotización)
POST/opportunities/:id/winy /lose — cierre de oportunidad
POST/quotes/:id/confirm-salecotización aprobada → venta + asiento + tareas
POST/quotes/:id/send | /approve | /reject | /reopentransiciones del ciclo de cotización
POST/invoices/from-sale/:saleIdfactura a partir de una venta cerrada
POST/invoices/:id/issue | /voidemitir (correlativo + asiento) o anular
POST/purchase-orders/:id/send | /receiverecibir es lo que mueve stock
POST/stock/transfers/:id/completeconfirma una transferencia entre bodegas
POST/payroll-runs/:id/approvecalcula y postea IGSS/ISR al libro mayor
GET/reports?from&todesglose comercial por empleado en un rango
GET/reports/financial?from&toestado financiero desde el libro mayor
GET/statsKPIs del Dashboard (estado actual, sin rango)
PATCH/users/:id/companiescambia el rol de una membresía

21 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