# Deep dive #4 — User portal MVP (`/my-projects` con auth low-friction)

**Status**: ✅ Sprint 1 + Sprint 2 técnicos DONE (2026-05-05 + 2026-05-06). Live en producción con clientes reales (coke, surnuevonorte) y 9 demos para Leandro provisionados via pipeline real. Pendiente Sprint 3 (marketplace embedded + profile + notifications in-app + DB testing setup).
**Frente master**: #4 NEXT (acelerado a NOW por urgencia de UX para clientes nuevos)
**Tiempo invertido**: ~2.5 hr sprint técnico
**Plan**: [`~/.claude/plans/si-plasmemos-bien-el-twinkling-wreath.md`](~/.claude/plans/si-plasmemos-bien-el-twinkling-wreath.md)

---

## Por qué este frente

Bewpro.com está saliendo al mercado con `#9 Marketing` arrancando + el funnel de Stripe Checkout activo (audit en `12a-legal.md`, `01-analytics-hotjar.md`). Cada cliente que paga necesita poder:

- Ver el estado de su sitio (provisioning / live / suspended)
- Acceder al panel admin de su proyecto
- Gestionar pagos (cambiar tarjeta, cancelar, descargar facturas) — vía Stripe Billing Portal
- Solicitar reembolso (política liberal documentada en `/legal/refund`)

Hoy solo había un flow `/my-billing` que pedía email y redirigía a Stripe Billing Portal — sin login real, sin lista de proyectos, sin link al admin del sitio.

---

## Diseño aplicado (Sprint 1)

### Auth low-friction: Google OAuth + email/password

```
NUEVO CLIENTE (nunca registrado):
  paga en bewpro.com → recibe OrderReceivedMail
                       con bloque [Continuar con Google] + [Acceder con email]
  ↓
  Click "Continuar con Google" → 1 click → /my-projects
  o
  Click "Acceder con email" → /forgot-password (email pre-filled) → setea password → login → /my-projects

CLIENTE EXISTENTE:
  bewpro.com/my-projects → /login → [Continuar con Google] o email+password → dashboard
```

### Stack reutilizado al 100%

- **Laravel Breeze** — login, register, password reset, email verification (vistas + controllers via stubs)
- **Laravel Socialite ^5.2** — Google OAuth
- **Spatie Permission** — rol `Client` con 3 permisos (`projects.view-own`, `subscription.manage-own`, `support.contact`)
- **Cashier** — `User->stripe_id` link + `Cashier::stripe()->billingPortal->sessions->create()` para gestión Stripe
- **AirtableService** — `findProjectByStripeCustomer()` para auto-vincular (Sprint 2)
- **OrderReceivedMail** existente — extendido con CTA dual

### Idempotencia

- `tenant_projects.stripe_customer_id` UNIQUE → retries de Stripe webhook NO duplican.
- `User::firstOrCreate()` por email → registro vía Google de un User ya creado por webhook → login en lugar de fail.
- `$user->assignRole('Client')` → Spatie no duplica si ya tiene el rol.

### Multi-tenant guard

`EnsureMainSite` middleware con whitelist `BEWPRO_MAIN_HOSTS=bewpro.com,localhost,127.0.0.1`. Aplicado a:
- `/register`, `/forgot-password`, `/reset-password`
- `/auth/redirect/{provider}`, `/auth/callback/{provider}`
- `/verify-email`
- `/my-projects/*`

Hosts no incluidos → 404. Garantiza que tenants pagantes (cliente-X.com) no expongan estas rutas.

> Nota: `/login` y `/logout` NO tienen el middleware — el admin debe poder loguear en cualquier tenant para administrar.

---

## Archivos creados / modificados

### Creados
| Path | Propósito |
|---|---|
| `database/migrations/2026_05_05_000001_create_tenant_projects_table.php` | Schema completo |
| `app/Models/TenantProject.php` | Eloquent + accessors |
| `app/Services/ClientProvisioningService.php` | Encapsula creación idempotente User+TenantProject |
| `app/Http/Controllers/Client/MyProjectsController.php` | `index()` + `billingPortal()` |
| `app/Policies/TenantProjectPolicy.php` | Solo dueño puede ver/manage |
| `app/Http/Middleware/EnsureMainSite.php` | Multi-tenant guard |
| `app/Http/Controllers/Auth/RegisteredUserController.php` | Form low-friction (`name+email+password`) |
| `app/Http/Controllers/Auth/SocialiteController.php` | Google OAuth |
| `app/Http/Controllers/Auth/{EmailVerificationPrompt,EmailVerificationNotification,VerifyEmail,PasswordResetLink,NewPassword}Controller.php` | Stubs Breeze (con redirect ajustado a `/my-projects` si rol Client) |
| `database/seeders/ClientPermissionsSeeder.php` | 3 permisos asignados al rol Client |
| `resources/views/client/my-projects/index.blade.php` | UI dashboard con cards |

### Modificados
| Path | Cambio |
|---|---|
| `routes/auth.php` | Activadas rutas Breeze + socialite + middleware ensure.main.site |
| `routes/web.php` | Grupo `/my-projects` con auth+verified+role:Client+ensure.main.site |
| `app/Http/Kernel.php` | Alias `'ensure.main.site'` |
| `app/Models/User.php` | `tenantProjects()` HasMany |
| `app/Providers/AuthServiceProvider.php` | Registrado `TenantProjectPolicy` |
| `app/Http/Controllers/StripeWebhookController.php` | Hook `provisionFromCheckout` post-Airtable |
| `database/seeders/RolesSeeder.php` | Agregado rol `Client` |
| `app/Mail/OrderReceivedMail.php` template | Bloque CTA dual (Google + email) |
| `resources/views/auth/login.blade.php` | Botón Google prominente arriba del form |
| `resources/views/auth/register.blade.php` | Form simplificado (`name`) + botón Google |
| `.env.example` | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `BEWPRO_MAIN_HOSTS` |

---

## Schema `tenant_projects`

```
id                      bigint pk
user_id                 unsignedBigInt FK → users.id ON DELETE CASCADE
stripe_customer_id      string UNIQUE      ← match key idempotencia
stripe_subscription_id  string nullable UNIQUE
airtable_record_id      string nullable    ← Sprint 2 sync target
project_name            string
slug                    string nullable    ← core slug
domain                  string nullable
pipeline_status         string default 'required'
                        ← required | on_development | active | paused | archived
product_name            string nullable
amount_usd              decimal(10,2) nullable
trial_ends_at           timestamp nullable
grace_period_end        date nullable
cpanel_user             string nullable
server                  string nullable
last_synced_at          timestamp nullable
timestamps
```

---

## Verificación E2E (post-deploy en producción 2026-05-05)

| Endpoint | Resultado |
|---|---|
| `https://bewpro.com/register` | ✅ HTTP 200 + form muestra `name+email+password` |
| `https://bewpro.com/login` | ✅ HTTP 200 + botón "Continuar con Google" prominente |
| `https://bewpro.com/forgot-password` | ✅ HTTP 200 |
| `https://bewpro.com/my-projects` (sin auth) | ✅ 302 → `/login` |
| `https://bewpro.com/auth/redirect/google` | ✅ 302 (esperable, redirige a Google — pero falla sin secret) |
| Rol Client + 3 permisos en bp-bewpro | ✅ Seeded en producción |
| Tabla `tenant_projects` en bp-bewpro | ✅ migrada |

---

## ⚠️ Pendiente humano para activar Google OAuth

Sin esto, el botón "Continuar con Google" aparece pero falla en el redirect. Email+password sigue funcionando independiente.

### Pasos (15 min)

1. **Crear OAuth credentials en Google Cloud Console**
   - https://console.cloud.google.com/ → seleccionar/crear proyecto.
   - APIs & Services → OAuth consent screen → External → completar (app name: BewPro, support email: cd.bewpro@gmail.com).
   - APIs & Services → Credentials → Create Credentials → OAuth client ID → Application type: Web application.
   - Authorized JavaScript origins: `https://bewpro.com`
   - Authorized redirect URIs: `https://bewpro.com/auth/callback/google`
   - Copiar `Client ID` + `Client secret`.

2. **Setear en VPS1** (.env de bewpro):

```bash
ssh vps1-claude
cd /home/bewpro22/public_html/bewpro
sudo -u bewpro22 nano .env
# Agregar al final:
#   GOOGLE_CLIENT_ID=<paste>
#   GOOGLE_CLIENT_SECRET=<paste>
#   BEWPRO_MAIN_HOSTS=bewpro.com,localhost,127.0.0.1
sudo -u bewpro22 /opt/cpanel/ea-php82/root/usr/bin/php artisan config:clear
```

3. **Smoke test**:
   - Visitar `https://bewpro.com/login` → click "Continuar con Google" → debe redirigir a Google consent screen.
   - Login con un email de prueba → callback debería crear User + redirect a `/my-projects` (lista vacía si no tenés Stripe customer asociado).

---

## Audit de scope del Client (2026-05-06)

Routes con guard `block.clients` aplicado (Client logueado entrando ahí → 302 a `/my-projects`):

| Path | Owner | Razón |
|------|-------|-------|
| `/admin/*` (4 grupos: cd-system, cd-base contact-messages, cd-base company-logos, onboarding) | Admin del CMS | No es panel del cliente pagante |
| `/home` | DashboardController.index | Panel admin del CMS |
| `/delivery-guide` | DashboardController.deliveryGuide | Guía interna de provisioning |
| `/blog/posts`, `/blog/categories` | Blog admin | Admin de contenido del CMS |
| `/products` (admin), `/products/categories`, `/products/tags` | Products admin | Catálogo del tenant |
| `/tokko-settings/*` | Tokko admin | Integración de inmobiliarias |

Routes que **SÍ** quedan accesibles para Client:

| Path | Razón |
|------|-------|
| `/my-projects`, `/my-projects/{id}/billing` | Su propio dashboard |
| `/subscriptions/*` (Cashier) | Gestión de su propia suscripción Stripe |
| `/login`, `/logout`, `/register`, `/forgot-password`, `/reset-password`, `/verify-email` | Auth |
| `/auth/redirect/google`, `/auth/callback/google` | OAuth |
| `/legal/*` | Pages legales de bewpro |
| `/`, `/about`, `/contact` y demás del demo público | Marketing site |

Plus tenant routes (`<cliente>.bewpro.com/*`) — NO afectadas por estos middlewares.

---

## Mejoras propuestas para el panel `/my-projects` (priorizadas)

Recopilación de features valiosas para un cliente con N proyectos pagantes. Ordenadas por ROI vs esfuerzo.

### P0 — Quick wins (ya hechos en sprint anterior + scope fix de hoy)

- ✅ Dashboard con stats bar + tabla de proyectos
- ✅ Botón "Comprar nuevo proyecto" → llama al flow auth-first
- ✅ Por proyecto: link al admin del sitio, botón Stripe Billing Portal, botón refund
- ✅ Toast de bienvenida tras checkout exitoso (`?welcome=1`)
- ✅ Scope bloqueado correctamente — Client no accede a admin

### P1 — Próximo sprint (1.5-2 hr)

| # | Feature | Por qué importa | Implementación |
|---|---------|-----------------|----------------|
| 1 | **Vista detalle de proyecto** (`/my-projects/{id}`) | Más info que la fila de tabla — historial pagos, status provisión paso a paso, métricas básicas | Nueva route + controller `show()` + vista. ~30 min |
| 2 | **Edit perfil** (`/my-account`) | Cambiar nombre, email, teléfono. Estándar SaaS | Form + validación + save. ~30 min |
| 3 | **Cambiar contraseña** (`/my-account/password`) | Self-service vs forgot-password | Form Breeze ya existe (PasswordController) — solo route + UI |
| 4 | **Notificaciones in-app** | Pago exitoso, pago fallido, sitio caído, etc. — sin depender solo de email | DB notifications via Laravel Notifications + dropdown UI |
| 5 | **Historial de pagos** | Cliente quiere ver "qué pagué + recibos" | Stripe API → tabla cronológica con link a invoice PDF |

### P2 — Sprint 3 (cuando hay volume real)

| # | Feature | Detalle |
|---|---------|---------|
| 6 | Cross-sell visual ("agregar Blog a tu sitio") | Modal o widget con productos compatibles con el core actual |
| 7 | Métricas del sitio (visitas, conversiones) | Embedeer GA4 widgets en la vista detalle del proyecto |
| 8 | Pausa temporal (vacaciones) | Suspender suscripción 1-2 meses sin cancelar — Stripe pause |
| 9 | Solicitudes de feature/cambio | Form estructurado en lugar de mailto, con tickets ID |
| 10 | Vincular/desvincular Google account | Si registró con email/password, después agregar Google |
| 11 | 2FA (TOTP) | Feature de seguridad para clientes que lo pidan |

### P3 — Futuro (cuando el portal sea producto core)

| # | Feature | Detalle |
|---|---------|---------|
| 12 | Welcome tour interactivo | Onboarding guiado para nuevos clientes |
| 13 | Wizard "configurá tu sitio paso a paso" | Reemplaza el wizard de admin de tenants — guía desde bewpro.com |
| 14 | Marketplace de plugins/integraciones | Apps de terceros instalables |
| 15 | Multi-cuenta / agencies | Reseller pueda gestionar N proyectos de N clientes desde 1 dashboard |
| 16 | Audit log per cliente | "Tu sitio recibió 1247 visitas la última semana" |

---

## Sprint 2 ✅ DONE 2026-05-06

### Refactor a módulo canónico (`app/Modules/Clients/`)

```
app/Modules/Clients/Controllers/MyProjectsController.php
app/Modules/Clients/Services/ClientPortalService.php   ← ensureAdminLayout, getInvoicesForUser, resolveSubscriptionDetails, getStatusDescription
resources/views/modules/clients/my-projects/{index,show,billing}.blade.php
routes/modules/clients.php
config/cd-system.php → entry 'clients'
```

### Vista detalle (`/my-projects/{project}`) y billing (`/my-projects/billing`)

- Show: alerta status humano + URL clickeable + Suscripción (próx cobro, plan, estado, trial) + Acciones + "¿Querés más?" (mailto). **NO expone server/cpanel/Stripe IDs** (regla anclada en MEMORY.md).
- Billing: invoices via Cashier + descarga PDF (`dompdf/dompdf:^2.0` instalado).

### Emails lifecycle con CTA primary "Ir a mi panel"

`SiteProvisionedMail` (secundario), `SubscriptionSuspendedMail`, `SubscriptionCancelledMail`, `GracePeriodWarningMail`. Los 4 templates actualizados.

### Comandos artisan nuevos

- `bewpro:sync-tenant-projects` — cron cada 10min. Auto-link de TPs huérfanos (stripe_customer_id sin airtable_record_id → lookup en Airtable Projects y popula).
- `bewpro:retry-failed-projects --email= --server=` — manual. PATCH Airtable SERVER + Pipeline_Status=Required.
- `bewpro:link-clients [--create-user-only] [--dry-run]` — backfill quirúrgico.
- `bewpro:setup-leandro-demos [--enrich] [--reset-status] [--sync-status]` — one-shot 9 demos.

### Tests escritos (sin correr — falta DB testing setup)

- `tests/Feature/MyProjectsDashboardTest.php` (5 tests)
- `tests/Feature/StripeCheckoutWebhookCreatesClientTest.php` (3 tests)
- `tests/Feature/SocialiteGoogleLoginTest.php` (4 tests con Mockery)
- `database/factories/TenantProjectFactory.php`

Para correrlos: descomentar `DB_CONNECTION=sqlite` + `DB_DATABASE=:memory:` en `phpunit.xml`.

---

## Casos reales resueltos en Sprint 2

### 9 demos para Leandro (junior nuevo)

User `leandro@lacompaniadigital.com` con 9 productos publicados al mercado provisionados como compras reales (mock Stripe IDs `cus_demo_leandro_*`):

| Slug | Status final |
|---|---|
| creative-video-editor, corporative, construction, art-design, law-firm-digital | ✅ on_development en VPS Hostinger 1 / Donweb 1 |
| foundations-ong, real-estate, restaurant-bar, personal-brand | 🟡 retry post-cleanup en VPS Hostinger 1 |

**Hallazgo**: 4 fallaron porque cuentas cPanel huérfanas en VPS Donweb 1 ocupaban el subdominio en DNS. Solución: `whmapi1 removeacct user=X keepdns=0` para los 4 → reintento limpio.

### `coke_colombres@hotmail.com` (cliente real, 2 subs)

2 TenantProjects con `airtable_record_id=NULL` (creados por `bewpro:link-clients` cuando lookup falló). Se veían "— provisionando —" indefinidamente. Fix: auto-link en cron sync.

### `surnuevonorte@gmail.com` (renovación hosting → duplicado fantasma)

Pagó "Hosting & Mantenimiento Web Dinámica" (Stripe `prod_TccuDuBDlsVTCB` USD 150/año) por su sitio existente. Webhook NO encontró matching local → creó duplicado fantasma con `name="Proyecto sin nombre"`. Federico recibió email titulado *"Recibimos tu pedido — Proyecto sin nombre"*.

Cleanup: re-mapear TP local al proyecto Active + PATCH Airtable + borrar fantasma + verificar email.

**Fix preventivo en `StripeWebhookController`**: si `metadata.product_name` vacío, resolve via Stripe API (`subscriptions.retrieve` expand product). Fallback `projectTitle`: customer_name → productName → email local-part → "Tu proyecto BewPro".

### Sub-fixes UI/UX desplegados

| Fix | Por qué importaba |
|---|---|
| CTA "Comenzar nuevo proyecto" → `/products-catalogue` | Era "Comprar" + `/`, poco específico |
| `.cd-status-banner` con CSS `!important` | Skin del demo daba texto blanco/blanco en alert-light |
| `hasUsableBilling()` esconde botón si `cus_demo_*` o `cus_test_*` | Demos disparaban error "No such customer" en Stripe Portal |
| `dompdf/dompdf:^2.0` instalado | Cashier `downloadInvoice` lo requiere |
| Return type `Symfony\Component\HttpFoundation\Response` | Cashier retorna la base de Symfony, no Illuminate |
| `@php(destructuring) → @php` block | Shorthand blade no compilaba bien |
| `NewPasswordController.create` pasa `$token`+`$email` | Vista esperaba ambos, controller solo pasaba `request` |
| Post-register redirige directo a `/verify-email` | Evita bounce intermedio del middleware `verified` |

---

## KPIs / éxito

| Métrica | Baseline | Goal 30 días post-deploy |
|---|---|---|
| Clientes pagantes con cuenta activa | 0 (no existía login) | 100% de nuevos pagantes |
| Tasa de "Continuar con Google" vs email+password | n/a | esperado: 60-70% Google |
| Tickets de soporte "no puedo acceder a mi sitio" | desconocido | <2/semana |
| Time-to-first-login post-purchase | n/a | <5 min mediana |
| TenantProjects sincronizados con Airtable | 0 (sprint 2) | 100% post-Sprint 2 |

---

## Decisiones registradas

| Pregunta | Decisión | Razón |
|---|---|---|
| Google OAuth en Fase 1 o Fase 2? | **Fase 1** | User priorizó low-friction. Sin Google = 2 emails para empezar. Con Google = 1 click |
| Form `name` único o `first_name+last_name`? | **`name`** único | Menos campos = menos fricción. Parsing en accesor si hace falta |
| Tabla local de proyectos o solo Airtable? | **Tabla local** (`tenant_projects`) | Evita pegarle a Airtable en cada page-load del dashboard |
| Rol nuevo o reutilizar `Member`? | **Rol nuevo `Client`** | `Member` es genérico; `Client` explícito para clientes pagantes |
| Magic link standalone? | **Descartado MVP** | 3 caminos (Google, email+password, forgot-password) alcanzan |
| Email verification para Google OAuth? | **Skip** (ya verificó Google) | Sino se rompe low-friction |
| `email_verified_at = now()` en webhook? | **Sí** (Stripe ya verificó al cobrar) | Equivalente a OAuth bypass |
| Validación password requirements complejos? | **No** — solo min 8 chars | UX-friendly. Stripe + Google + email verification cubren riesgo |
| Refactor a módulo canónico `app/Modules/Clients/`? (Sprint 2) | **Sí** | Alinea con patrón Blog/Products/Services. Codebase consistente |
| Mostrar `server` y `cpanel_user` al cliente? (Sprint 2) | **NO, regla absoluta** | Es metadata de operación interna. El cliente compró un proyecto, no un VPS. Anclado en MEMORY.md |
| Marketplace embedded en Sprint 2? | **No, Sprint 3** | Priorización: detalle + billing + emails ya cubren UX core. Marketplace puede esperar a tener volumen real |
| Crear `TenantProject` separado para renovaciones? (Sprint 2 case surnuevonorte) | **Issue conocido — fix preventivo aplicado** | El webhook resolvía mal cuando producto Stripe sin metadata. Sprint 3 contempla matching por email a TPs activos preexistentes |

---

## Riesgos identificados y mitigaciones aplicadas

| Riesgo | Mitigación |
|---|---|
| Webhook nuevo logic rompe checkout en producción | `provisionFromCheckout` con try/catch interno + log + return null. Webhook continúa con Airtable existente |
| Rutas auth expuestas en tenants accidentalmente | `EnsureMainSite` middleware con whitelist por env |
| Email duplicado entre Google OAuth y password | `users.email` UNIQUE; SocialiteController loguea User existente |
| Race condition webhook vs sync cron Sprint 2 | `updateOrCreate` con `stripe_customer_id` UNIQUE. Cualquiera que llegue primero gana |
| Cliente paga pero ProvisioningService falla | Sprint 2 backfill desde Stripe + Airtable lo recupera |
| Tenants ya provisionados sin User asociado | Sprint 2 `bewpro:backfill-clients` los crea retroactivamente |

---

*Sprint 1 ✅ 2026-05-05. Sprint 2 ✅ 2026-05-06 — refactor a módulo canónico + show + billing history + emails lifecycle con CTA al portal + 12 tests escritos + auto-link sync + 9 demos provisionados para Leandro + 3 casos reales resueltos (Leandro 4 retry, coke huérfanos, surnuevonorte fantasma) + 8 sub-fixes UI/UX*.

## Sprint 3 (futuro escalado, no priorizado)

| # | Feature | Esfuerzo |
|---|---|---|
| 1 | Marketplace embedded (`/my-projects/shop`): solicitar módulos extras, cambiar plan/producto, comprar nuevo proyecto sin salir | 4-6 hr |
| 2 | Profile + change password sin pasar por reset email | 1-1.5 hr |
| 3 | Notifications in-app (Laravel Notifications + dropdown UI) | 2-3 hr |
| 4 | Health check HTTP por proyecto (cron HEAD a `https://{domain}/`) | 1 hr |
| 5 | Setup DB testing (sqlite:memory:) → correr los 12 tests escritos | 30 min |
| 6 | Métricas embed (GA4 widgets en show) | 2-3 hr |
| 7 | Pausa temporal de suscripción (Stripe pause) | 1 hr |
| 8 | Vincular Google account post-registro email/password | 1 hr |
| 9 | **Webhook**: matching por email a TenantProjects activos preexistentes (renovaciones no crean duplicados) | 1.5 hr |
