# CORE TRANSVERSAL — sistemas que aplican a los 8 productos

> **Regla de oro:** si una de estas 14 capas falla, **NINGÚN producto puede salir a venta**.
> El core es responsabilidad del sistema base; los fixes acá benefician a los 8 productos al mismo tiempo.
>
> **Última auditoría completa:** 2026-04-27

---

## Mapa de las 14 capas

| # | Capa | Responsabilidad | Riesgo si falla |
|---|------|-----------------|-----------------|
| A | Pipeline de provisión | Crea tenant nuevo desde 0 (DB + tablas + seeds + assets) | Cliente paga y no recibe sitio |
| B | Multi-tenant routing | Resuelve `host → DB` en cada request | Todos los tenants ven la misma data |
| C | Cloudinary | Almacenamiento de assets por tenant | Logos rotos / sitio sin imágenes |
| D | Skin / colors generator | Convierte 6 hex a ~120 CSS vars | Sitio con colores rotos o ilegibles |
| E | Pipeline de venta | Stripe → Airtable → Cron → Setup script | Compras fallidas, no se provisiona |
| F | DNS automático | Crea `{base}.bewpro.com` vía Hostinger API | Sitio inaccesible públicamente |
| G | cPanel automation | Crea cuenta cPanel + DB + user vía WHM | No hay aislamiento por tenant |
| H | Email post-provision | Envía credenciales al cliente | Cliente no sabe que recibió su sitio |
| I | Cron + queue | Procesa cola de provisión | Compras quedan en limbo |
| J | Catálogo | Stripe products + DB products + catalog.json sincronizados | No se puede comprar |
| K | Componentes shared | header-nav, header-cta, footer, page-header | Bug en 1 = bug en los 8 |
| L | Helpers críticos | site_asset_url, brand_og_image, get_dynamic_navigation | URLs rotas, OG meta vacíos |
| M | Vistas admin compartidas | site-data tabs + `_common` partials | Cliente no puede editar su sitio |
| N | Brand kit wizard | Cliente sube logos + cambia colores post-entrega | Sitio queda con branding del demo |

---

## A. Pipeline de provisión

**Archivos**:
- `app/Console/Commands/ProvisionProject.php` — base de 14 steps
- `app/Console/Commands/ProvisionNew.php` — wrapper `bewpro:new` (resuelve shop slug → core)
- `app/Services/ProvisionAssetResolver.php` — resuelve subdir del producto

**Flujo**:
```
bewpro:new EMAIL "Título" SLUG --db=bp-X --fresh
  ↓
ProvisionNew::handle()
  ├─ resuelve shop slug → core preset → escribe users.json temp
  ├─ array_replace_recursive(preset, commandData) → projectData
  └─ doProvision()
      ├─ ProvisionAssetResolver::configureFromProjectData()  ← resolver activo desde aquí
      ├─ Step 1: switchDatabase
      ├─ Step 2: migrate (fresh o incremental)
      ├─ Step 3: writeCdSystemConfig (demo + módulos activos)
      ├─ Step 4: seedBaseData (permissions, roles, users, settings)
      ├─ Step 4b: merge preset config (welcome/about/contact desde config-{core}.json)
      ├─ Step 4c: stepWriteBrandDefaults (colores + fonts)
      ├─ Step 5: writeSiteData (paths con subdir vía prefixAssetPath helper)
      ├─ Step 6: writeAnalytics
      ├─ Step 7: stepProcessAssets → AssetsSeeder → Cloudinary
      ├─ Step 7b: stepResolveAssetUrlsInSettings (paths locales → URLs Cloudinary)
      ├─ Step 8: stepInstallContentDefaults + stepSeedModuleContent
      ├─ Step 10: stepInstallCdBaseDefault + stepSeedCdBase
      └─ Step 12: stepClearCache
```

**Validación**:
```bash
php artisan bewpro:new admin@test.com "Test" {slug} --db=bp-test --fresh --dry-run
# Esperado: muestra el plan de 14 pasos sin errores

php artisan bewpro:new admin@test.com "Test" {slug} --db=bp-test --fresh
# Esperado: 14 pasos OK, exit 0
```

**Last check**: 2026-04-27 — `bewpro:new` corre OK contra los 8 cores existentes.

---

## B. Multi-tenant routing

**Archivos**:
- `app/Providers/TenantServiceProvider.php` — middleware que cambia `database.connections.mysql.database` por request
- `config/tenants.php` — mapping `host → db_name`

**Cómo funciona**: lee `request()->getHost()` → busca en `tenants.php` → setea config DB → reconnect.

**⚠️ ESTADO DEL CORE**:
- En **local**: `config/tenants.php` existe con 3 entradas (radocbikes, surnuevonorte, getmuma)
- En **server (bewpro22)**: archivo **NO existe** → todos los requests caen al `.env` DB (`bewpro22_bp` = sitio principal)
- **Tenants productivos** (cdbuildings, cdestudio, etc.) tienen su propia cuenta cPanel + DB + DocumentRoot independiente, no usan `tenants.php`

**Decisión arquitectónica**: el sitio principal `bewpro.com` se sirve desde `bewpro22_bp`. Los demos públicos `{slug}.bewpro.com` requieren cuenta cPanel propia + provisión completa (NO se sirven desde la cuenta principal con `tenants.php`).

**Validación**:
```bash
# Verificar que cada tenant tiene su propio cPanel
ssh root@server "ls /home/ | grep -E '_bp$'"
```

---

## C. Cloudinary

**Archivos**:
- `app/Services/ProjectAssetService.php` — uploads a `{db_name_sin_bp-}/assets/`
- `database/seeders/AssetsSeeder.php` — sube cada archivo del `assets.json` resolviendo subdir del producto
- `config/cloudinary.php` + `.env` `CLOUDINARY_URL`

**Folder por tenant**: `getProjectName()` lee `DB::connection()->getDatabaseName()`, strip prefix `bewpro-/bp-` y `/_db|_dev|_test|_prod|_local/i` → `{name}/assets/`.

Ejemplo: DB `bp-demo-law-firm-digital` → folder Cloudinary `demo-law-firm-digital/assets/`.

**Path canónico en `Asset::path` y `settings`**: `cd-project/assets/{subdir-producto}/{file}` (post-fix de 2026-04-27). Antes era ruta plana raíz, lo cual mezclaba assets entre productos.

**Validación**:
```bash
# Después de bewpro:new, esperar 6 URLs absolutas en settings
mysql {DB} -e "SELECT key, value FROM settings WHERE key LIKE 'site.assets.%' AND value LIKE 'https://res.cloudinary.com%'"
# Esperado: 6 filas (main_logo, footer_logo, loader_logo, favicon, apple_touch_icon, main_logo_sticky)
```

**Risk**: si Cloudinary credentials fallan, AssetsSeeder loguea warning pero continúa. Settings quedan con paths locales → en producción 404. Hay que monitorear logs.

---

## D. Skin / colors generator

**Archivos**:
- `app/Services/SkinColorService.php` — `generateCssVariables(array $colors): string`
- `resources/views/layout/front/partials/_styles.blade.php` — inyecta `<style id="brandColors">` en cada request
- `app/Console/Commands/GenerateSkin.php` — `bewpro:skin` para generar archivo CSS estático (opcional)

**Cómo funciona**:
1. Cliente edita 6 colores en wizard → persiste en `settings` `site.theme.colors.*`
2. Cada request: `_styles.blade.php` lee los 6 colores y llama `SkinColorService::generateCssVariables()`
3. Se genera `:root { --primary: #X; --primary-100..300; --primary--100..300; --primary-rgba-0..90; (idem para los 6 colores) ... --grey-100..1000; --default; --grey; }`
4. El `<style id="brandColors">` se renderiza al final del `<head>` → gana cascade vs el skin estático

**Bugs históricos arreglados (2026-04-27)**:
- `--default: #A0A0A0` → `#555555` (el anterior era ilegible sobre blanco)
- Escala greys invertida (`--grey-100: #2A2A2A` casi negro) → estándar Bootstrap (100=`#F4F4F4` light, 1000=`#212121` dark). Antes, todas las `section.section { background: var(--grey-100); }` salían con fondo casi negro.

**Validación**:
```bash
curl -s {tenant_url} | grep -E "^\s*--(grey-100|grey-1000|default|primary|secondary):"
# Esperado:
#   --default: #555555
#   --grey-100: #F4F4F4
#   --grey-1000: #212121
#   --primary: #{hex del producto}
#   --secondary: #{hex del producto}
```

---

## E. Pipeline de venta

**Archivos / componentes**:
1. `app/Http/Controllers/StripeWebhookController.php` (en bewpro22) — recibe `checkout.session.completed`
2. `app/Services/AirtableService.php` — `createSubscriptionRecord()` con Pipeline_Status="Required"
3. `/root/scripts/process-airtable.sh` (cron `*/5 * * * *`) — escanea Airtable, asigna VPS dual
4. `/root/scripts/setup_cd_project4.sh` (root, llamado por process-airtable) — crea cPanel + DB + DNS + clone + bewpro:new
5. `/opt/cd-system-reference.git` — bare repo local que clones nuevos usan como reference (acelera y desconecta de github)
6. `app/Mail/SiteProvisionedMail.php` — envía credenciales al cliente al final

**Flujo end-to-end**:
```
Cliente paga en Stripe (35 USD/mes para law-firm-digital)
  ↓ webhook checkout.session.completed
StripeWebhookController @ bewpro22
  ├─ identifica producto por stripe_price_id
  ├─ AirtableService::createSubscriptionRecord(Pipeline_Status='Required')
  └─ SlackService::provisionCreated() (notifica equipo)
        ↓
process-airtable.sh (cron cada 5 min, server master root)
  ├─ AIRTABLE GET donde Pipeline_Status=Required
  ├─ Para cada record:
  │   ├─ resuelve product slug → setup_cd_project4.sh args
  │   ├─ asigna VPS (dual: vps1 / vps2 — round-robin o por carga)
  │   └─ exec setup_cd_project4.sh BASE_NAME EMAIL TITLE PRODUCT [PASS] [DOMAIN] [SOURCE_DB]
  └─ AIRTABLE PATCH Pipeline_Status='On Development', Cpanel_User, Provisioned_DB, Server
        ↓
setup_cd_project4.sh (10 steps, root)
  ├─ [1/10] Crea cuenta cPanel `{BASE}` con dominio `{BASE}.bewpro.com` (WHM API)
  ├─ [2/10] Crea DB MySQL `{BASE}_bp` y user `{BASE}_bpuser` (WHM API)
  ├─ [3/10] Configura DNS A record `{BASE}.bewpro.com → 72.61.45.136` (Hostinger API)
  ├─ [4/10] Configura PHP version + límites + .htaccess en cPanel
  ├─ [5/10] git clone --reference /opt/cd-system-reference.git --branch cd-system → /home/{BASE}/public_html/bewpro
  ├─ [6/10] composer install --no-dev --optimize-autoloader
  ├─ [7/10] genera .env con DB creds, APP_URL, CLOUDINARY_URL heredado
  ├─ [8/10] php artisan bewpro:new EMAIL "TITLE" PRODUCT --db={BASE}_bp --fresh → tenant aprovisionado
  ├─ [9/10] storage:link, view:cache, route:cache, config:cache
  └─ [10/10] curl -sI https://{BASE}.bewpro.com/ → smoke test 200
        ↓
SiteProvisionedMail → cliente
  ├─ URL del sitio: https://{BASE}.bewpro.com
  ├─ URL admin: https://{BASE}.bewpro.com/login
  ├─ Email: {EMAIL del checkout}
  └─ Password: {generado por el setup}
```

**Validación end-to-end**: hacer una compra de prueba (modo Stripe TEST con tarjeta `4242 4242 4242 4242`) y monitorear:
```bash
# 1. Stripe webhook
ssh root@server "tail -f /home/bewpro22/public_html/bewpro/storage/logs/laravel.log | grep -i stripe"

# 2. Airtable poller
ssh root@server "tail -f /var/log/process-airtable.log"

# 3. Verificar tenant en ~5-30 min
curl -sI https://{base_name}.bewpro.com/  # esperado: 200
```

---

## F. DNS automático (Hostinger API)

**Componente**: `setup_cd_project4.sh` step [3/10] usa `HOSTINGER_TOKEN` de `/root/scripts/.airtable.env`.

**Crea**: `A record` `{base}.bewpro.com → 72.61.45.136`. Sin TTL custom.

**Validación**:
```bash
dig +short {base}.bewpro.com
# Esperado: 72.61.45.136
```

**Risk**: si Hostinger cambia API o el token expira, los nuevos tenants quedan sin DNS y `setup_cd_project4` aborta en step [3].

---

## G. cPanel automation (WHM API)

**Componente**: `setup_cd_project4.sh` steps [1/10] y [2/10] usan WHM API local (`/usr/local/cpanel/bin/whmapi1`).

**Crea**:
- Cuenta cPanel `{BASE}` con quota, ramload, DocumentRoot `/home/{BASE}/public_html`
- DB MySQL `{BASE}_bp` (con prefix automático del cPanel) + user `{BASE}_bpuser` con todas las grants

**Validación**:
```bash
ssh root@server "whmapi1 listaccts | grep {BASE}"
ssh root@server "mysql -e \"SHOW GRANTS FOR '{BASE}_bpuser'@'localhost'\""
```

---

## H. Email post-provision

**Archivo**: `app/Mail/SiteProvisionedMail.php`

**Trigger**: `ProvisionNew::handle()` final, luego del `doProvision()` exitoso. Skip si `--no-email`.

**Contenido**: title, productName, adminEmail, adminPassword (en plano), adminUrl (`{APP_URL}/login`).

**Validación**:
```bash
# Provisionar con email real y verificar Bandeja de entrada
php artisan bewpro:new tu-email@dominio.com "Test" {slug} --db=bp-test --fresh
# Esperado: email recibido en ~10s
```

**Risk**: requiere `MAIL_*` vars OK en .env. Si falla, el setup script igual exit 0 pero cliente no recibe credenciales.

---

## I. Cron + queue

**Crontab root del server master** (último check 2026-04-27):
```
*/5 * * * * /root/scripts/process-airtable.sh >> /var/log/process-airtable.log 2>&1
*  * * * * /root/scripts/process_provision_queue.sh   ← ⚠️ DEAD CODE, apunta a /home/resellerprueba2/ que ya no existe
```

**Acción pendiente**: remover el segundo cron (queue legacy) del crontab.

**Lock**: `process-airtable.sh` usa `/tmp/process-airtable.lock` para evitar concurrent runs. Timeout 1h.

---

## J. Catálogo

**3 fuentes que deben sincronizar**:

| Fuente | Ubicación | Llave |
|--------|-----------|-------|
| `catalog.json` | `database/seeders/products/catalog.json` | shop_slug → core + name |
| Tabla `products` en DB del sitio principal (`bewpro22_bp`) | MySQL | shop_slug + stripe_price_id + is_active |
| Stripe Products | Stripe dashboard | price_id (ej. `price_1T9RidLFhmxhEqlTRyDuZqk9`) |
| Airtable Shop Products | Base appRxv... tbl czbF5tQ... | Slug, Name, Price_USD, Resume |
| Airtable Shop Products Copy | tbl dxXvyo1... | Headline, Subheadline, Keywords |

**Comando de sync**: `php artisan bewpro:catalog:seed [--dry-run] [--update]` lee Airtable y upsert en `bewpro22_bp.products`.

**Validación**:
```bash
mysql bewpro22_bp -e "SELECT slug, name, price, stripe_price_id, is_active FROM products WHERE is_active = 1"
# Esperado: 8 productos activos para los 8 cores
```

**Estado actual** (2026-04-27): **solo `law-firm-digital` con `is_active=1`**. Los otros 7 cores tienen su shop product creado pero `is_active=0` → no se pueden comprar.

---

## K. Componentes shared

**Lista**:
- `resources/views/components/header-nav.blade.php` — usado por todos los headers
- `resources/views/components/header-cta.blade.php` — botón CTA del header
- `resources/views/components/breadcrumb.blade.php` — varias páginas
- `resources/views/components/page-title.blade.php` — page headers

**Bug histórico (2026-04-27)**: `<x-header-nav>` no recibía `$serviceCategories` ni `$headerServices` (componentes anónimos no heredan scope). Fixed: auto-fetch inline.

**Validación cross-product**: cualquier cambio acá afecta a los 8 — toca QA visual de los 8 demos antes de mergear.

---

## L. Helpers críticos

| Helper | Archivo | Qué hace |
|--------|---------|----------|
| `site_asset_url($key)` | `app/helpers.php:1736` | Resuelve `site.assets.{key}` a URL completa (Cloudinary o local) |
| `brand_og_image()` | `app/helpers.php:1777` | Devuelve og:image preferido o fallback al main_logo |
| `brand_twitter_image()` | `app/helpers.php:1792` | Idem para twitter:image |
| `get_dynamic_navigation($type)` | `app/helpers.php:998` | Construye nav header/footer desde modules activos + config |
| `is_module_active($module)` | `app/helpers.php` | Lee `cd-system.modules.{module}.active` desde DB |
| `front_homepage_url()` | `app/helpers.php` | URL de inicio del sitio (route o /) |

**Validación**: rendering smoke test del front debe mostrar URLs Cloudinary en logos, og:image válido, navegación con N items.

---

## M. Vistas admin compartidas

**Archivos**:
- `resources/views/admin/site-data/index.blade.php` — orquesta los 4 tabs (general, welcome, about, contact)
- `resources/views/admin/site-data/welcome/_common.blade.php` — campos comunes welcome
- `resources/views/admin/site-data/about/_common.blade.php` — campos comunes about
- `resources/views/admin/site-data/contact/_common.blade.php` — campos comunes contact (phone, email, address, lat/lng, page_title, etc.)
- `resources/views/admin/site-data/{tab}/{demo}.blade.php` — campos custom del demo

**Patrón**: `index.blade` hace `@includeFirst([demo-específico, _default])` después de `@include('_common')`. Cada demo agrega sus campos custom encima de los comunes.

**Risk**: `_common` puede tener campos que el demo no consume → cliente edita pero no ve cambios. Decisión actual: no se borran porque otros demos los usan.

---

## N. Brand kit wizard

**Archivos**:
- `app/Services/BrandKitService.php` — orquesta logos + colores + fonts
- `app/Services/FaviconGenerator.php` — genera favicon/og-image desde logo-2 + primary color (Cloudinary transforms)
- `resources/views/wizard/brand-kit/*.blade.php` — UI del wizard
- `app/Http/Controllers/BrandKitWizardController.php` — endpoints draft/apply

**Flujo cliente**:
1. Cliente entra al admin → ve banner "Personalizá tu marca"
2. Wizard 3 pasos: logos × 3 → colores × 6 → fonts × 3
3. Cada paso guarda a `brand_kit_draft.*` en settings (NO toca producción)
4. "Apply" final: `BrandKitService::applyDraft()` promueve staging → folder final, persiste en `site.assets.*` + `site.theme.colors.*`, genera derivados (favicon/og), borra draft
5. Cliente puede "Reset" = vuelve a `brand_kit.core_defaults.*` (snapshot inmutable del producto)

**Sources tracked**:
- `core_default` — primer provision
- `reseller_prefill` — si reseller prellenó colores con `--colors`
- `wizard` — cliente completó el wizard
- `admin_edit` — cliente editó colores manualmente desde settings

**Validación**: post-apply, `site.assets.*` debe tener URLs Cloudinary diferentes a las del core. `brand_kit.completed=1`.

---

## QA TRANSVERSAL — antes de declarar el core production-ready

```bash
# 1. ProvisionProject corre OK contra los 8 cores
for slug in law-firm-digital art-design construction corporative foundations-ong personal-brand real-estate restaurant-bar; do
  php artisan bewpro:new test@x.com "T" $slug --db=bp-test-$slug --fresh --no-email --dry-run | grep -E '^Step|exit'
done

# 2. SkinColorService genera los 6 colores correctamente
php artisan tinker --execute="echo App\Services\SkinColorService::generateCssVariables(['primary'=>'#1A325D']);" | grep -E "^\s*--(grey-100|default):"
# Esperado: --default: #555555 ; --grey-100: #F4F4F4

# 3. Cloudinary configurado
grep CLOUDINARY_URL .env  # debe existir

# 4. Stripe configurado
grep -E "^STRIPE_(KEY|SECRET|WEBHOOK_SECRET)|^CASHIER_CURRENCY" .env  # 4 vars

# 5. Cron del server activo
ssh root@server "crontab -l | grep process-airtable"

# 6. Reference repo actualizado
ssh root@server "git -C /opt/cd-system-reference.git log --oneline cd-system | head -3"

# 7. Catálogo coherente
mysql bewpro22_bp -e "SELECT COUNT(*) FROM products WHERE is_active = 1 AND stripe_price_id IS NOT NULL"
# Esperado: 8 (cuando los 8 productos estén listos)

# 8. Helpers no rompen sin contexto
php artisan tinker --execute="echo site_asset_url('main_logo');" 2>&1 | head -1
```

---

## Bugs transversales arreglados (changelog del core)

| Fecha | Bug | Archivo | Fix |
|-------|-----|---------|-----|
| 2026-04-27 | `--default: #A0A0A0` (ilegible) | `SkinColorService.php:37` | `#555555` |
| 2026-04-27 | Escala greys invertida → secciones casi negras | `SkinColorService.php:58-67` | Estándar Bootstrap 100=light/1000=dark |
| 2026-04-27 | `buildSiteData()` hardcodeaba paths a raíz | `ProvisionProject.php` | Helper `prefixAssetPath()` con resolver |
| 2026-04-27 | Asset resolver se configuraba en step 7 (tarde) | `ProvisionProject::doProvision()` | Configurado al inicio |
| 2026-04-27 | `Asset::path` mismatch con settings | `AssetsSeeder.php` | Path canónico con subdir |
| 2026-04-27 | `HomepageController::contact()` no pasaba `$faqs` | `HomepageController.php` | Carga featured FAQs |
| 2026-04-27 | `<x-header-nav>` con dropdown vacío | `components/header-nav.blade.php` | Auto-fetch inline categories/services |

---

## Referencias

- Pipeline de provisión: [`../branding/pipeline-provision.md`](../branding/pipeline-provision.md)
- Branding: [`../branding/proceso-branding.md`](../branding/proceso-branding.md)
- Skin CSS: [`../branding/skin-css.md`](../branding/skin-css.md)
- Vistas base: [`../arquitectura-vistas-base.md`](../arquitectura-vistas-base.md)
- Provision queue: [`../bewpro-provision.php`](../bewpro-provision.php) (legacy webhook)
- Setup script: `/root/scripts/setup_cd_project4.sh` (en server master)
- Process Airtable: `/root/scripts/process-airtable.sh` (en server master)
