diff --git a/copy/COPY-GUIDE.md b/copy/COPY-GUIDE.md index ca86765..e9ae178 100644 --- a/copy/COPY-GUIDE.md +++ b/copy/COPY-GUIDE.md @@ -120,6 +120,12 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll - **Pain 2:** 🥶 **Se enfrían en WhatsApp** — Mientras estás en obra, el cliente espera respuesta y consulta a la competencia. - **Pain 3:** 🏃 **Se van con tu trabajo** — Hacen el presupuesto contigo, lo llevan al de al lado para que se lo baje, y tú pierdes una obra que era tuya. +> **Variante implementada en la landing** (`mvp/b2b/landing_reformix.html`, sección "problema") — tres tarjetas por etapa del embudo del reformista: +> +> - **01 · CAPTACIÓN** — *Pagas por el mismo lead que cinco competidores.* / Los portales venden cada contacto varias veces. Ganas el que más rápido llame, no el que mejor trabaje. El cliente acaba agotado de llamadas. +> - **02 · VISITA** — *Conduces 40 km y era cambiar un grifo.* / Sin información previa, cada visita es una apuesta. Una de cada tres no acaba en presupuesto, y muchas de las que sí, no compensan el desplazamiento. +> - **03 · PRESUPUESTO** — *Presupuestas gratis para quien no firma.* / Mides, calculas y montas el PDF: horas de oficina que no factura nadie. La mayoría no acaba en obra, así que ese trabajo lo regalas tú. + ### Bloque "Cómo funciona Reformix" - **Título:** Le pones la herramienta en tu web. El resto lo hace ella. diff --git a/docs/superpowers/plans/2026-05-30-auth-panel-planes.md b/docs/superpowers/plans/2026-05-30-auth-panel-planes.md new file mode 100644 index 0000000..52a01a1 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-auth-panel-planes.md @@ -0,0 +1,1811 @@ +# Auth + Multi-tenant + Admin de Planes — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Añadir login propio del reformista, aislamiento multi-tenant real y un área admin que gestiona usuarios y asigna planes (Stripe en stub, sin cobro) a la app `mvp/b2c`. + +**Architecture:** Sesión server-side (tabla `sessions` + cookie httpOnly), contraseñas con `bcryptjs`. La lógica de autorización se extrae a funciones puras (`src/lib/auth/authz.ts`, `tokens.ts`, `password.ts`) testeables sin DB; los wrappers con DB/cookies (`session.ts`, `current-user.ts`) quedan fuera de cobertura. La resolución de tenant pasa de un `TENANT_SLUG` hardcodeado a `getCurrentTenantId()` derivado del usuario logueado, sin tocar los filtros `tenantId` ya existentes en las queries. + +**Tech Stack:** Next.js 16 (App Router, Server Actions), Drizzle ORM + postgres.js, Postgres, bcryptjs, zod v4, Vitest. + +**Spec:** `docs/superpowers/specs/2026-05-30-auth-panel-planes-design.md` + +--- + +## Convenciones del repo (leer antes de empezar) + +- `mvp/b2c` usa **npm** (hay `package-lock.json`), no pnpm. Todos los comandos corren desde `mvp/b2c/`. +- Imports con alias `@/` → `src/`. Tests en `tests/` espejando `src/`. +- Dinero en **céntimos** (enteros). TypeScript strict, sin `any`. +- Next.js 16: `cookies()` de `next/headers` es **async** (`const store = await cookies()`). +- Migraciones Drizzle: `npm run db:generate` crea SQL en `drizzle/`. No editar SQL a mano. +- No hay DB en CI: los tests Vitest deben ser **puros** (sin conexión a Postgres). + +--- + +## File Structure + +**Crear:** +- `src/lib/auth/password.ts` — hash/verify de contraseña (bcryptjs). Puro, testeable. +- `src/lib/auth/tokens.ts` — generación/hash de token de sesión y cálculo de expiración. Puro, testeable. +- `src/lib/auth/authz.ts` — tipo `AuthUser` + decisiones de autorización puras (`isAdmin`, `assertAdmin`, `resolveTenantId`, `canAccessTenant`). +- `src/lib/auth/session.ts` — ciclo de vida de la sesión (DB + cookie). Excluido de cobertura. +- `src/lib/auth/current-user.ts` — `getCurrentUser`, `requireUser`, `requireAdmin`, `getCurrentTenantId` (DB + redirect). Excluido de cobertura. +- `src/lib/validation/signup.ts` — schema zod del signup. Puro, testeable. +- `src/lib/billing/plan.ts` — `trialDaysRemaining`, `formatPlanBadge`. Puro, testeable. +- `src/lib/billing/stripe.ts` — interfaz stub (sin llamadas reales). Excluido de cobertura. +- `src/db/admin-queries.ts` — queries del área admin (listar/crear usuarios y tenants, asignar plan/estado). +- `src/db/auth-queries.ts` — `getUserByEmail`, `createTenantWithOwner`. +- `src/app/login/page.tsx`, `src/app/login/actions.ts` — login. +- `src/app/logout/route.ts` — logout (POST). +- `src/app/signup/page.tsx`, `src/app/signup/actions.ts` — signup trial. +- `src/app/admin/layout.tsx`, `src/app/admin/page.tsx` — dashboard admin. +- `src/app/admin/usuarios/page.tsx`, `src/app/admin/usuarios/actions.ts` — gestión usuarios. +- `src/app/admin/planes/page.tsx`, `src/app/admin/planes/actions.ts` — ver/asignar planes. +- Tests: `tests/auth/password.test.ts`, `tests/auth/tokens.test.ts`, `tests/auth/authz.test.ts`, `tests/validation/signup.test.ts`, `tests/billing/plan.test.ts`. + +**Modificar:** +- `src/db/schema.ts` — enums `user_role`/`user_status`/`subscription_status`, tablas `users`/`sessions`/`plans`, columnas nuevas en `tenants`. +- `src/db/queries.ts`, `src/db/pricing-queries.ts`, `src/app/panel/actions.ts` — sustituir `getTenantId()` local por `getCurrentTenantId()`. +- `src/app/panel/layout.tsx` — guard `requireUser`, nombre de empresa real, botón logout. +- `src/app/panel/page.tsx` — badge de plan/estado del tenant. +- `src/db/seed.ts` — sembrar planes, admin y owner logueable de "Reformas Ejemplo". +- `vitest.config.ts` — extender `coverage.include` a los módulos puros nuevos. +- `package.json` — dependencia `bcryptjs`. +- `mvp/b2b/landing_reformix.html` — CTAs de trial → `/signup`. + +--- + +## FASE 1 — Fundación de auth + +### Task 1: Añadir dependencia bcryptjs + +**Files:** +- Modify: `package.json` (dependencies) + +- [ ] **Step 1: Instalar bcryptjs (incluye tipos en v3)** + +Run: `npm install bcryptjs@^3` +Expected: `package.json` lista `"bcryptjs": "^3..."` en dependencies; sin errores. + +- [ ] **Step 2: Verificar que resuelve y trae tipos** + +Run: `npx tsc --noEmit -p tsconfig.json` +Expected: exit 0 (bcryptjs aún no se importa; solo confirma que el árbol compila). + +- [ ] **Step 3: Commit** + +```bash +git add package.json package-lock.json +git commit -m "Add bcryptjs para hashing de contraseñas" +``` + +--- + +### Task 2: Hashing de contraseña (puro, TDD) + +**Files:** +- Create: `src/lib/auth/password.ts` +- Test: `tests/auth/password.test.ts` + +- [ ] **Step 1: Escribir el test que falla** + +```typescript +// tests/auth/password.test.ts +import { describe, it, expect } from 'vitest'; +import { hashPassword, verifyPassword } from '@/lib/auth/password'; + +describe('password', () => { + it('verifica una contraseña correcta contra su hash', async () => { + const hash = await hashPassword('Reforma2026!'); + expect(hash).not.toBe('Reforma2026!'); + expect(await verifyPassword('Reforma2026!', hash)).toBe(true); + }); + + it('rechaza una contraseña incorrecta', async () => { + const hash = await hashPassword('Reforma2026!'); + expect(await verifyPassword('otra', hash)).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Ejecutar el test para verlo fallar** + +Run: `npm test -- tests/auth/password.test.ts` +Expected: FAIL con "Cannot find module '@/lib/auth/password'". + +- [ ] **Step 3: Implementación mínima** + +```typescript +// src/lib/auth/password.ts +import bcrypt from 'bcryptjs'; + +const SALT_ROUNDS = 10; + +export function hashPassword(plain: string): Promise { + return bcrypt.hash(plain, SALT_ROUNDS); +} + +export function verifyPassword(plain: string, hash: string): Promise { + return bcrypt.compare(plain, hash); +} +``` + +- [ ] **Step 4: Ejecutar el test para verlo pasar** + +Run: `npm test -- tests/auth/password.test.ts` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/auth/password.ts tests/auth/password.test.ts +git commit -m "Add hashing y verificación de contraseña" +``` + +--- + +### Task 3: Token y expiración de sesión (puro, TDD) + +**Files:** +- Create: `src/lib/auth/tokens.ts` +- Test: `tests/auth/tokens.test.ts` + +- [ ] **Step 1: Escribir el test que falla** + +```typescript +// tests/auth/tokens.test.ts +import { describe, it, expect } from 'vitest'; +import { + generateSessionToken, + hashSessionToken, + isSessionExpired, + sessionExpiry, + SESSION_TTL_MS, +} from '@/lib/auth/tokens'; + +describe('tokens', () => { + it('genera tokens distintos en hex de 64 chars', () => { + const a = generateSessionToken(); + const b = generateSessionToken(); + expect(a).toMatch(/^[0-9a-f]{64}$/); + expect(a).not.toBe(b); + }); + + it('hashea el token de forma determinista y distinta al token', () => { + const t = generateSessionToken(); + expect(hashSessionToken(t)).toBe(hashSessionToken(t)); + expect(hashSessionToken(t)).not.toBe(t); + }); + + it('marca expirada una sesión pasada y válida una futura', () => { + const now = new Date('2026-05-30T12:00:00Z'); + expect(isSessionExpired(new Date('2026-05-29T12:00:00Z'), now)).toBe(true); + expect(isSessionExpired(new Date('2026-05-31T12:00:00Z'), now)).toBe(false); + }); + + it('sessionExpiry suma el TTL al instante dado', () => { + const now = new Date('2026-05-30T12:00:00Z'); + expect(sessionExpiry(now).getTime()).toBe(now.getTime() + SESSION_TTL_MS); + }); +}); +``` + +- [ ] **Step 2: Ejecutar el test para verlo fallar** + +Run: `npm test -- tests/auth/tokens.test.ts` +Expected: FAIL con "Cannot find module '@/lib/auth/tokens'". + +- [ ] **Step 3: Implementación mínima** + +```typescript +// src/lib/auth/tokens.ts +import { randomBytes, createHash } from 'node:crypto'; + +export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 días + +export function generateSessionToken(): string { + return randomBytes(32).toString('hex'); +} + +export function hashSessionToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +export function isSessionExpired(expiresAt: Date, now: Date = new Date()): boolean { + return expiresAt.getTime() <= now.getTime(); +} + +export function sessionExpiry(now: Date = new Date()): Date { + return new Date(now.getTime() + SESSION_TTL_MS); +} +``` + +- [ ] **Step 4: Ejecutar el test para verlo pasar** + +Run: `npm test -- tests/auth/tokens.test.ts` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/auth/tokens.ts tests/auth/tokens.test.ts +git commit -m "Add generación y expiración de tokens de sesión" +``` + +--- + +### Task 4: Decisiones de autorización (puro, TDD) + +**Files:** +- Create: `src/lib/auth/authz.ts` +- Test: `tests/auth/authz.test.ts` + +- [ ] **Step 1: Escribir el test que falla** + +```typescript +// tests/auth/authz.test.ts +import { describe, it, expect } from 'vitest'; +import { + isAdmin, + assertAdmin, + resolveTenantId, + canAccessTenant, + type AuthUser, +} from '@/lib/auth/authz'; + +const reformista: AuthUser = { + id: 'u1', email: 'r@x.com', nombre: 'R', role: 'reformista', tenantId: 't1', status: 'activo', +}; +const admin: AuthUser = { + id: 'u2', email: 'a@x.com', nombre: 'A', role: 'admin', tenantId: null, status: 'activo', +}; + +describe('authz', () => { + it('isAdmin distingue roles', () => { + expect(isAdmin(admin)).toBe(true); + expect(isAdmin(reformista)).toBe(false); + }); + + it('assertAdmin lanza si no es admin o es null', () => { + expect(() => assertAdmin(admin)).not.toThrow(); + expect(() => assertAdmin(reformista)).toThrow(); + expect(() => assertAdmin(null)).toThrow(); + }); + + it('resolveTenantId devuelve el tenant del reformista y lanza para admin/null', () => { + expect(resolveTenantId(reformista)).toBe('t1'); + expect(() => resolveTenantId(admin)).toThrow(); + expect(() => resolveTenantId(null)).toThrow(); + }); + + it('canAccessTenant: reformista solo el suyo, admin cualquiera', () => { + expect(canAccessTenant(reformista, 't1')).toBe(true); + expect(canAccessTenant(reformista, 't2')).toBe(false); + expect(canAccessTenant(admin, 't2')).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Ejecutar el test para verlo fallar** + +Run: `npm test -- tests/auth/authz.test.ts` +Expected: FAIL con "Cannot find module '@/lib/auth/authz'". + +- [ ] **Step 3: Implementación mínima** + +```typescript +// src/lib/auth/authz.ts +export type Role = 'reformista' | 'admin'; +export type UserStatus = 'activo' | 'deshabilitado'; + +export type AuthUser = { + id: string; + email: string; + nombre: string | null; + role: Role; + tenantId: string | null; + status: UserStatus; +}; + +export function isAdmin(user: AuthUser): boolean { + return user.role === 'admin'; +} + +export function assertAdmin(user: AuthUser | null): asserts user is AuthUser { + if (!user || user.role !== 'admin') { + throw new Error('Acceso restringido a administradores.'); + } +} + +export function resolveTenantId(user: AuthUser | null): string { + if (!user || !user.tenantId) { + throw new Error('El usuario no tiene un tenant asociado.'); + } + return user.tenantId; +} + +export function canAccessTenant(user: AuthUser, tenantId: string): boolean { + if (user.role === 'admin') return true; + return user.tenantId === tenantId; +} +``` + +- [ ] **Step 4: Ejecutar el test para verlo pasar** + +Run: `npm test -- tests/auth/authz.test.ts` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/auth/authz.ts tests/auth/authz.test.ts +git commit -m "Add decisiones de autorización puras" +``` + +--- + +### Task 5: Schema — users, sessions, plans y columnas de suscripción + +**Files:** +- Modify: `src/db/schema.ts` + +- [ ] **Step 1: Añadir enums nuevos tras los enums existentes (después de `unidadMedida`, línea ~56)** + +```typescript +export const userRole = pgEnum('user_role', ['reformista', 'admin']); +export const userStatus = pgEnum('user_status', ['activo', 'deshabilitado']); +export const subscriptionStatus = pgEnum('subscription_status', [ + 'trial', + 'activo', + 'cancelado', + 'vencido', +]); +``` + +- [ ] **Step 2: Añadir columnas de suscripción a `tenants` (dentro del objeto `tenants`, antes de `createdAt`)** + +```typescript + planId: uuid('plan_id').references((): any => plans.id), + subscriptionStatus: subscriptionStatus('subscription_status').notNull().default('trial'), + trialEndsAt: timestamp('trial_ends_at', { withTimezone: true }), + stripeCustomerId: text('stripe_customer_id'), +``` + +> `plans` se define más abajo; la referencia perezosa `(): any =>` evita el problema de orden de declaración. Mantén el resto de columnas de `tenants` intactas. + +- [ ] **Step 3: Añadir tablas nuevas tras la tabla `tenants`** + +```typescript +export const plans = pgTable('plans', { + id: uuid('id').primaryKey().defaultRandom(), + slug: text('slug').notNull().unique(), + nombre: text('nombre').notNull(), + precioMensual: integer('precio_mensual').notNull(), // céntimos + leadsIncluidos: integer('leads_incluidos').notNull(), + features: jsonb('features').$type().notNull().default([]), + activo: boolean('activo').notNull().default(true), +}); + +export const users = pgTable( + 'users', + { + id: uuid('id').primaryKey().defaultRandom(), + email: text('email').notNull().unique(), + passwordHash: text('password_hash').notNull(), + nombre: text('nombre'), + role: userRole('role').notNull().default('reformista'), + tenantId: uuid('tenant_id').references(() => tenants.id, { onDelete: 'cascade' }), + status: userStatus('status').notNull().default('activo'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [index('users_tenant_idx').on(table.tenantId)] +); + +export const sessions = pgTable( + 'sessions', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + tokenHash: text('token_hash').notNull().unique(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [index('sessions_user_idx').on(table.userId)] +); +``` + +- [ ] **Step 4: Añadir los tipos inferidos al final del archivo (junto a los `export type` existentes)** + +```typescript +export type Plan = typeof plans.$inferSelect; +export type User = typeof users.$inferSelect; +export type NewUser = typeof users.$inferInsert; +export type Session = typeof sessions.$inferSelect; +``` + +- [ ] **Step 5: Verificar que compila** + +Run: `npx tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 6: Generar la migración** + +Run: `npm run db:generate` +Expected: imprime "Your SQL migration file ➜ drizzle/0001_*.sql" (o índice siguiente); el SQL crea los enums, tablas y columnas. + +- [ ] **Step 7: Commit** + +```bash +git add src/db/schema.ts drizzle/ +git commit -m "Add schema de users, sessions, plans y suscripción de tenant" +``` + +--- + +### Task 6: Sesión (DB + cookie) y helpers de usuario actual + +**Files:** +- Create: `src/lib/auth/session.ts` +- Create: `src/lib/auth/current-user.ts` +- Create: `src/db/auth-queries.ts` + +- [ ] **Step 1: Query de usuario por email + creación de tenant con owner** + +```typescript +// src/db/auth-queries.ts +import { eq } from 'drizzle-orm'; +import { db } from './index'; +import { users, tenants } from './schema'; +import { sessionExpiry } from '@/lib/auth/tokens'; + +export async function getUserByEmail(email: string) { + const [row] = await db.select().from(users).where(eq(users.email, email)).limit(1); + return row ?? null; +} + +export async function getUserById(id: string) { + const [row] = await db.select().from(users).where(eq(users.id, id)).limit(1); + return row ?? null; +} + +export async function createTenantWithOwner(input: { + nombreEmpresa: string; + slug: string; + provincia: string | null; + email: string; + passwordHash: string; + nombre: string | null; +}) { + const [tenant] = await db + .insert(tenants) + .values({ + slug: input.slug, + nombreEmpresa: input.nombreEmpresa, + provincia: input.provincia, + subscriptionStatus: 'trial', + trialEndsAt: sessionExpiry(new Date()), // placeholder TTL; ajustado abajo a 14 días + }) + .returning(); + + const [user] = await db + .insert(users) + .values({ + email: input.email, + passwordHash: input.passwordHash, + nombre: input.nombre, + role: 'reformista', + tenantId: tenant.id, + }) + .returning(); + + return { tenant, user }; +} +``` + +> **Nota:** el `trialEndsAt` correcto (14 días) lo fija la action de signup (Task 11) tras crear el tenant; aquí se pone un valor por defecto que la action sobreescribe. No dupliques el cálculo de 14 días en dos sitios: la action es la fuente de verdad. + +- [ ] **Step 2: Implementar session.ts (cookie + DB)** + +```typescript +// src/lib/auth/session.ts +import { cookies } from 'next/headers'; +import { eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { sessions } from '@/db/schema'; +import { getUserById } from '@/db/auth-queries'; +import { + generateSessionToken, + hashSessionToken, + isSessionExpired, + sessionExpiry, +} from './tokens'; +import type { AuthUser } from './authz'; + +const COOKIE = 'session'; + +export async function createSession(userId: string): Promise { + const token = generateSessionToken(); + await db.insert(sessions).values({ + userId, + tokenHash: hashSessionToken(token), + expiresAt: sessionExpiry(), + }); + const store = await cookies(); + store.set(COOKIE, token, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + expires: sessionExpiry(), + }); +} + +export async function destroySession(): Promise { + const store = await cookies(); + const token = store.get(COOKIE)?.value; + if (token) { + await db.delete(sessions).where(eq(sessions.tokenHash, hashSessionToken(token))); + store.delete(COOKIE); + } +} + +export async function getSessionUser(): Promise { + const store = await cookies(); + const token = store.get(COOKIE)?.value; + if (!token) return null; + + const [session] = await db + .select() + .from(sessions) + .where(eq(sessions.tokenHash, hashSessionToken(token))) + .limit(1); + if (!session) return null; + + if (isSessionExpired(session.expiresAt)) { + await db.delete(sessions).where(eq(sessions.id, session.id)); + return null; + } + + const user = await getUserById(session.userId); + if (!user || user.status !== 'activo') return null; + + return { + id: user.id, + email: user.email, + nombre: user.nombre, + role: user.role, + tenantId: user.tenantId, + status: user.status, + }; +} +``` + +- [ ] **Step 3: Implementar current-user.ts (redirect semantics)** + +```typescript +// src/lib/auth/current-user.ts +import { redirect } from 'next/navigation'; +import { getSessionUser } from './session'; +import { resolveTenantId, type AuthUser } from './authz'; + +export async function getCurrentUser(): Promise { + return getSessionUser(); +} + +export async function requireUser(): Promise { + const user = await getSessionUser(); + if (!user) redirect('/login'); + return user; +} + +export async function requireAdmin(): Promise { + const user = await getSessionUser(); + if (!user) redirect('/login'); + if (user.role !== 'admin') redirect('/panel'); + return user; +} + +export async function getCurrentTenantId(): Promise { + const user = await getSessionUser(); + return resolveTenantId(user); +} +``` + +- [ ] **Step 4: Verificar que compila** + +Run: `npx tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/auth/session.ts src/lib/auth/current-user.ts src/db/auth-queries.ts +git commit -m "Add ciclo de vida de sesión y helpers de usuario actual" +``` + +--- + +### Task 7: Migrar resolución de tenant de slug a sesión + +**Files:** +- Modify: `src/db/queries.ts:13-17` +- Modify: `src/db/pricing-queries.ts:1-11,54` +- Modify: `src/app/panel/actions.ts:7,12-16` + +- [ ] **Step 1: queries.ts — reemplazar getTenantId local por el de sesión** + +En `src/db/queries.ts` elimina la función local `getTenantId` (líneas 13-17) y el import de `TENANT_SLUG` y `tenants` si quedan sin uso. Añade arriba: + +```typescript +import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user'; +``` + +Quita `tenants` del import de `./schema` y `TENANT_SLUG` de `@/lib/funnel` si ya no se usan en el archivo. El resto de funciones (`getLeads`, `getLead`, `getResumen`) no cambian: siguen llamando `await getTenantId()`. + +- [ ] **Step 2: pricing-queries.ts — misma sustitución, manteniendo el export** + +En `src/db/pricing-queries.ts` elimina la función local `getTenantId` (líneas 7-11) y el import de `TENANT_SLUG`. Añade: + +```typescript +import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user'; +``` + +Mantén `export { getTenantId };` al final (línea 54) para no romper a `precios/actions.ts`, que lo importa. Quita `tenants` del import de `./schema` si queda sin uso. + +- [ ] **Step 3: panel/actions.ts — usar el helper de sesión** + +En `src/app/panel/actions.ts` elimina la función local `getTenantId` (líneas 12-16) y el import de `TENANT_SLUG` (línea 7) y de `tenants` (línea 6). Añade: + +```typescript +import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user'; +``` + +Las tres actions ya llaman `await getTenantId()`; no cambian. + +- [ ] **Step 4: Verificar que compila y que no quedan referencias a TENANT_SLUG en queries** + +Run: `npx tsc --noEmit` +Expected: exit 0. + +Run: `git grep -n "TENANT_SLUG" -- src/db src/app/panel` +Expected: sin resultados (TENANT_SLUG solo puede quedar en `src/lib/funnel.ts` y `src/db/seed.ts`). + +- [ ] **Step 5: Commit** + +```bash +git add src/db/queries.ts src/db/pricing-queries.ts src/app/panel/actions.ts +git commit -m "Migrar resolución de tenant del panel a la sesión" +``` + +--- + +### Task 8: Login y logout + +**Files:** +- Create: `src/app/login/page.tsx` +- Create: `src/app/login/actions.ts` +- Create: `src/app/logout/route.ts` + +- [ ] **Step 1: Server action de login** + +```typescript +// src/app/login/actions.ts +'use server'; + +import { redirect } from 'next/navigation'; +import { getUserByEmail } from '@/db/auth-queries'; +import { verifyPassword } from '@/lib/auth/password'; +import { createSession } from '@/lib/auth/session'; + +export async function login(_prev: string | null, formData: FormData): Promise { + const email = String(formData.get('email') ?? '').trim().toLowerCase(); + const password = String(formData.get('password') ?? ''); + if (!email || !password) return 'Introduce email y contraseña.'; + + const user = await getUserByEmail(email); + if (!user || user.status !== 'activo') return 'Credenciales incorrectas.'; + if (!(await verifyPassword(password, user.passwordHash))) return 'Credenciales incorrectas.'; + + await createSession(user.id); + redirect(user.role === 'admin' ? '/admin' : '/panel'); +} +``` + +- [ ] **Step 2: Página de login (Client Component con useActionState)** + +```tsx +// src/app/login/page.tsx +'use client'; + +import { useActionState } from 'react'; +import { login } from './actions'; + +export default function LoginPage() { + const [error, formAction, pending] = useActionState(login, null); + return ( +
+
+

Entra en tu panel

+ + + {error &&

{error}

} + +
+
+ ); +} +``` + +- [ ] **Step 3: Route de logout** + +```typescript +// src/app/logout/route.ts +import { redirect } from 'next/navigation'; +import { destroySession } from '@/lib/auth/session'; + +export async function POST() { + await destroySession(); + redirect('/login'); +} +``` + +- [ ] **Step 4: Verificar que compila** + +Run: `npx tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/login src/app/logout +git commit -m "Add login y logout" +``` + +--- + +### Task 9: Guard del panel + cabecera con sesión + +**Files:** +- Modify: `src/app/panel/layout.tsx` + +- [ ] **Step 1: Convertir el layout en async con guard y datos de sesión** + +```tsx +// src/app/panel/layout.tsx +import Link from 'next/link'; +import type { Metadata } from 'next'; +import { requireUser } from '@/lib/auth/current-user'; +import { db } from '@/db'; +import { tenants } from '@/db/schema'; +import { eq } from 'drizzle-orm'; + +export const metadata: Metadata = { + title: 'Panel · Reformix', + description: 'Panel de leads del reformista', +}; + +export default async function PanelLayout({ children }: { children: React.ReactNode }) { + const user = await requireUser(); + const [tenant] = user.tenantId + ? await db.select().from(tenants).where(eq(tenants.id, user.tenantId)).limit(1) + : []; + const nombreEmpresa = tenant?.nombreEmpresa ?? 'Reformix'; + + return ( +
+
+
+ + + R + + Reformix + / + {nombreEmpresa} + + +
+
+
{children}
+
+ ); +} +``` + +- [ ] **Step 2: Verificar que compila** + +Run: `npx tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/panel/layout.tsx +git commit -m "Proteger el panel con sesión y mostrar empresa real" +``` + +--- + +### Task 10: Seed — planes, admin y owner logueable + +**Files:** +- Modify: `src/db/seed.ts` + +- [ ] **Step 1: Añadir imports de hashing y schema de auth (tras la línea 7)** + +```typescript +import { hashPassword } from '../lib/auth/password'; +``` + +> `schema.plans`, `schema.users` ya están disponibles vía `import * as schema`. + +- [ ] **Step 2: Incluir las tablas nuevas en el TRUNCATE (línea ~286)** + +Reemplaza la sentencia TRUNCATE para incluir `users`, `sessions` y `plans` (las dependientes primero por CASCADE no es estricto, pero añádelas explícitamente): + +```typescript + await db.execute( + sql`TRUNCATE TABLE ${schema.sessions}, ${schema.users}, ${schema.precisionHistory}, ${schema.leadPipelineEventos}, ${schema.leadEstadoHistory}, ${schema.leadFotos}, ${schema.catalogItems}, ${schema.pricingConfig}, ${schema.leads}, ${schema.plans}, ${schema.tenants} RESTART IDENTITY CASCADE` + ); +``` + +- [ ] **Step 3: Sembrar los 3 planes antes de crear el tenant (tras el TRUNCATE)** + +```typescript + console.log('Sembrando planes...'); + const [starter, pro] = await db + .insert(schema.plans) + .values([ + { slug: 'starter', nombre: 'Starter', precioMensual: 2900, leadsIncluidos: 5, + features: ['5 leads procesados / mes', '3 €/lead extra', 'Hasta 100 contactos', 'Branding básico'] }, + { slug: 'pro', nombre: 'Pro', precioMensual: 7900, leadsIncluidos: 15, + features: ['15 leads procesados / mes', '2,50 €/lead extra', 'White-label completo', 'Sub-flujo licencia urbanística', 'Integraciones Holded/Stel', 'Soporte prioritario'] }, + { slug: 'business', nombre: 'Business', precioMensual: 19900, leadsIncluidos: 50, + features: ['50 leads procesados / mes', '2 €/lead extra', 'Usuarios ilimitados', 'API', 'Multi-zona', 'Custom price book', 'Dashboard analytics'] }, + ]) + .returning(); +``` + +- [ ] **Step 4: Asignar plan Pro + trial al tenant al crearlo (modificar el insert de `tenants`, línea ~290)** + +```typescript + const [tenant] = await db + .insert(schema.tenants) + .values({ + slug: 'reformas-ejemplo', + nombreEmpresa: 'Reformas Ejemplo', + provincia: 'Madrid', + whatsappBusiness: '+34 600 000 000', + planId: pro.id, + subscriptionStatus: 'trial', + trialEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), + }) + .returning(); +``` + +- [ ] **Step 5: Crear admin + owner logueable (tras crear el tenant, antes del bucle de leads)** + +```typescript + console.log('Creando usuarios demo (admin + owner)...'); + await db.insert(schema.users).values([ + { + email: 'admin@reformix.es', + passwordHash: await hashPassword('AdminReformix2026!'), + nombre: 'Admin Reformix', + role: 'admin', + tenantId: null, + }, + { + email: 'demo@reformas-ejemplo.es', + passwordHash: await hashPassword('DemoReformix2026!'), + nombre: 'Reformas Ejemplo', + role: 'reformista', + tenantId: tenant.id, + }, + ]); +``` + +> Credenciales demo documentadas aquí: admin `admin@reformix.es` / `AdminReformix2026!`; reformista `demo@reformas-ejemplo.es` / `DemoReformix2026!`. El `starter` sembrado no se asigna a nadie todavía; queda disponible para que el admin lo asigne. + +- [ ] **Step 6: Verificar que compila** + +Run: `npx tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 7: Commit** + +```bash +git add src/db/seed.ts +git commit -m "Sembrar planes y usuarios demo (admin + owner logueable)" +``` + +--- + +## FASE 2 — Signup trial + +### Task 11: Schema zod del signup (puro, TDD) + +**Files:** +- Create: `src/lib/validation/signup.ts` +- Test: `tests/validation/signup.test.ts` + +- [ ] **Step 1: Escribir el test que falla** + +```typescript +// tests/validation/signup.test.ts +import { describe, it, expect } from 'vitest'; +import { signupSchema, slugify } from '@/lib/validation/signup'; + +describe('signupSchema', () => { + it('acepta un alta válida y normaliza email a minúsculas', () => { + const r = signupSchema.safeParse({ + nombre: 'Ana', email: 'Ana@X.com', empresa: 'Reformas Ana', + provincia: 'Madrid', password: 'Segura2026', optInMarketing: 'on', + }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.email).toBe('ana@x.com'); + }); + + it('rechaza email inválido y contraseña corta', () => { + expect(signupSchema.safeParse({ + nombre: 'Ana', email: 'no-mail', empresa: 'X', provincia: 'Madrid', password: '123', + }).success).toBe(false); + }); +}); + +describe('slugify', () => { + it('genera slug url-safe sin acentos', () => { + expect(slugify('Reformas Ándalus, S.L.')).toBe('reformas-andalus-s-l'); + }); +}); +``` + +- [ ] **Step 2: Ejecutar el test para verlo fallar** + +Run: `npm test -- tests/validation/signup.test.ts` +Expected: FAIL con "Cannot find module '@/lib/validation/signup'". + +- [ ] **Step 3: Implementación mínima** + +```typescript +// src/lib/validation/signup.ts +import { z } from 'zod'; + +export const signupSchema = z.object({ + nombre: z.string().trim().min(1, 'Indica tu nombre.'), + email: z.string().trim().toLowerCase().email('Email no válido.'), + empresa: z.string().trim().min(1, 'Indica el nombre de tu empresa.'), + provincia: z.string().trim().min(1, 'Indica tu provincia.'), + password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres.'), + optInMarketing: z.union([z.literal('on'), z.literal('')]).optional(), +}); + +export type SignupInput = z.infer; + +export function slugify(value: string): string { + return value + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} +``` + +- [ ] **Step 4: Ejecutar el test para verlo pasar** + +Run: `npm test -- tests/validation/signup.test.ts` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/validation/signup.ts tests/validation/signup.test.ts +git commit -m "Add validación y slug del signup" +``` + +--- + +### Task 12: Página y action de signup trial + +**Files:** +- Create: `src/app/signup/actions.ts` +- Create: `src/app/signup/page.tsx` +- Modify: `src/db/auth-queries.ts` (añadir unicidad de slug) + +- [ ] **Step 1: Helper de slug único en auth-queries.ts** + +Añade a `src/db/auth-queries.ts`: + +```typescript +export async function slugDisponible(slug: string): Promise { + const [row] = await db.select({ id: tenants.id }).from(tenants).where(eq(tenants.slug, slug)).limit(1); + return !row; +} +``` + +- [ ] **Step 2: Server action de signup** + +```typescript +// src/app/signup/actions.ts +'use server'; + +import { redirect } from 'next/navigation'; +import { eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { tenants } from '@/db/schema'; +import { signupSchema, slugify } from '@/lib/validation/signup'; +import { getUserByEmail, createTenantWithOwner, slugDisponible } from '@/db/auth-queries'; +import { hashPassword } from '@/lib/auth/password'; +import { createSession } from '@/lib/auth/session'; + +const TRIAL_MS = 14 * 24 * 60 * 60 * 1000; + +export async function signup(_prev: string | null, formData: FormData): Promise { + const parsed = signupSchema.safeParse(Object.fromEntries(formData)); + if (!parsed.success) return parsed.error.issues[0]?.message ?? 'Datos no válidos.'; + const data = parsed.data; + + if (await getUserByEmail(data.email)) return 'Ya existe una cuenta con ese email.'; + + let slug = slugify(data.empresa); + let n = 1; + while (!(await slugDisponible(slug))) slug = `${slugify(data.empresa)}-${++n}`; + + const passwordHash = await hashPassword(data.password); + const { tenant, user } = await createTenantWithOwner({ + nombreEmpresa: data.empresa, + slug, + provincia: data.provincia, + email: data.email, + passwordHash, + nombre: data.nombre, + }); + + await db + .update(tenants) + .set({ trialEndsAt: new Date(Date.now() + TRIAL_MS) }) + .where(eq(tenants.id, tenant.id)); + + await createSession(user.id); + redirect('/panel'); +} +``` + +- [ ] **Step 3: Página de signup (Client Component)** + +```tsx +// src/app/signup/page.tsx +'use client'; + +import { useActionState } from 'react'; +import { signup } from './actions'; + +export default function SignupPage() { + const [error, formAction, pending] = useActionState(signup, null); + return ( +
+
+

Empieza gratis 14 días

+

Sin tarjeta. Configura tu catálogo y recibe leads.

+ + + + + + + {error &&

{error}

} + + Ya tengo cuenta +
+
+ ); +} +``` + +- [ ] **Step 4: Verificar que compila** + +Run: `npx tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/signup src/db/auth-queries.ts +git commit -m "Add signup trial que crea tenant y owner" +``` + +--- + +### Task 13: Cablear los CTA de la landing B2B a /signup + +**Files:** +- Modify: `mvp/b2b/landing_reformix.html` + +- [ ] **Step 1: Localizar los CTA de trial** + +Run: `git grep -n -o -E "href=\"[^\"]*\"" -- mvp/b2b/landing_reformix.html | head -40` +Después busca los anclajes cuyo texto visible sea "Empieza gratis 14 días", "Pruébalo gratis 14 días" o "Empezar gratis 14 días". Para cada uno, lee el fragmento exacto con Read sobre el rango de la línea coincidente antes de editar. + +- [ ] **Step 2: Apuntar cada CTA de trial a /signup** + +Para cada anclaje de trial localizado, usa Edit cambiando su `href` actual (p. ej. `href="#pricing"` o `href="#signup"`) por `href="/signup"`. La landing se sirve en el mismo origen (`/b2b`), así que la ruta relativa `/signup` resuelve correctamente. No toques otros enlaces (FAQ, ancras internas de navegación). + +- [ ] **Step 3: Verificar que los CTA apuntan a /signup** + +Run: `git grep -n "/signup" -- mvp/b2b/landing_reformix.html` +Expected: una línea por cada CTA de trial editado. + +- [ ] **Step 4: Commit** + +```bash +git add mvp/b2b/landing_reformix.html +git commit -m "Cablear CTAs de trial de la landing B2B a /signup" +``` + +> El script `make-deploy-zip.ps1` ya copia `mvp/b2b/landing_reformix.html` → `mvp/b2c/public/b2b.html` en cada build, así que el cambio llega a producción sin pasos extra. + +--- + +## FASE 3 — Área admin + +### Task 14: Queries del admin + +**Files:** +- Create: `src/db/admin-queries.ts` + +- [ ] **Step 1: Implementar las queries (todas asumen que el caller ya validó admin)** + +```typescript +// src/db/admin-queries.ts +import { eq } from 'drizzle-orm'; +import { db } from './index'; +import { tenants, users, plans } from './schema'; + +export async function listTenants() { + return db.select().from(tenants).orderBy(tenants.createdAt); +} + +export async function listUsers() { + return db.select().from(users).orderBy(users.createdAt); +} + +export async function listPlans() { + return db.select().from(plans).where(eq(plans.activo, true)).orderBy(plans.precioMensual); +} + +export async function assignPlan(tenantId: string, planId: string) { + await db.update(tenants).set({ planId }).where(eq(tenants.id, tenantId)); +} + +export async function setSubscriptionStatus( + tenantId: string, + status: (typeof tenants.subscriptionStatus.enumValues)[number] +) { + await db.update(tenants).set({ subscriptionStatus: status }).where(eq(tenants.id, tenantId)); +} + +export async function setUserStatus( + userId: string, + status: (typeof users.status.enumValues)[number] +) { + await db.update(users).set({ status, updatedAt: new Date() }).where(eq(users.id, userId)); +} +``` + +- [ ] **Step 2: Verificar que compila** + +Run: `npx tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add src/db/admin-queries.ts +git commit -m "Add queries del área admin" +``` + +--- + +### Task 15: Layout y dashboard del admin + +**Files:** +- Create: `src/app/admin/layout.tsx` +- Create: `src/app/admin/page.tsx` + +- [ ] **Step 1: Layout con guard requireAdmin** + +```tsx +// src/app/admin/layout.tsx +import Link from 'next/link'; +import type { Metadata } from 'next'; +import { requireAdmin } from '@/lib/auth/current-user'; + +export const metadata: Metadata = { title: 'Admin · Reformix' }; + +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + await requireAdmin(); + return ( +
+
+
+ + R + Reformix + / + Admin + + +
+
+
{children}
+
+ ); +} +``` + +- [ ] **Step 2: Dashboard con conteos** + +```tsx +// src/app/admin/page.tsx +import { listTenants, listUsers, listPlans } from '@/db/admin-queries'; + +export const dynamic = 'force-dynamic'; + +export default async function AdminHome() { + const [tenants, users, plans] = await Promise.all([listTenants(), listUsers(), listPlans()]); + const cards = [ + { label: 'Reformistas (tenants)', value: tenants.length }, + { label: 'Usuarios', value: users.length }, + { label: 'Planes activos', value: plans.length }, + { label: 'En trial', value: tenants.filter((t) => t.subscriptionStatus === 'trial').length }, + ]; + return ( +
+

Resumen

+
+ {cards.map((c) => ( +
+
{c.value}
+
{c.label}
+
+ ))} +
+
+ ); +} +``` + +- [ ] **Step 3: Verificar que compila** + +Run: `npx tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/admin/layout.tsx src/app/admin/page.tsx +git commit -m "Add layout y dashboard del admin" +``` + +--- + +### Task 16: Gestión de usuarios (crear reformista, habilitar/deshabilitar) + +**Files:** +- Create: `src/app/admin/usuarios/actions.ts` +- Create: `src/app/admin/usuarios/page.tsx` + +- [ ] **Step 1: Actions (crear reformista, toggle estado)** + +```typescript +// src/app/admin/usuarios/actions.ts +'use server'; + +import { revalidatePath } from 'next/cache'; +import { requireAdmin } from '@/lib/auth/current-user'; +import { signupSchema, slugify } from '@/lib/validation/signup'; +import { getUserByEmail, createTenantWithOwner, slugDisponible } from '@/db/auth-queries'; +import { setUserStatus } from '@/db/admin-queries'; +import { hashPassword } from '@/lib/auth/password'; + +export async function crearReformista(_prev: string | null, formData: FormData): Promise { + await requireAdmin(); + const parsed = signupSchema.safeParse(Object.fromEntries(formData)); + if (!parsed.success) return parsed.error.issues[0]?.message ?? 'Datos no válidos.'; + const data = parsed.data; + if (await getUserByEmail(data.email)) return 'Ya existe una cuenta con ese email.'; + + let slug = slugify(data.empresa); + let n = 1; + while (!(await slugDisponible(slug))) slug = `${slugify(data.empresa)}-${++n}`; + + await createTenantWithOwner({ + nombreEmpresa: data.empresa, + slug, + provincia: data.provincia, + email: data.email, + passwordHash: await hashPassword(data.password), + nombre: data.nombre, + }); + revalidatePath('/admin/usuarios'); + return null; +} + +export async function toggleUsuario(formData: FormData) { + await requireAdmin(); + const userId = String(formData.get('userId')); + const next = String(formData.get('next')) as 'activo' | 'deshabilitado'; + await setUserStatus(userId, next); + revalidatePath('/admin/usuarios'); +} +``` + +- [ ] **Step 2: Página (lista + formulario de alta)** + +```tsx +// src/app/admin/usuarios/page.tsx +import { listUsers, listTenants } from '@/db/admin-queries'; +import { toggleUsuario } from './actions'; +import { CrearReformistaForm } from './CrearReformistaForm'; + +export const dynamic = 'force-dynamic'; + +export default async function UsuariosPage() { + const [users, tenants] = await Promise.all([listUsers(), listTenants()]); + const empresaDe = new Map(tenants.map((t) => [t.id, t.nombreEmpresa])); + return ( +
+

Usuarios

+ +
+ + + + + + + {users.map((u) => ( + + + + + + + + ))} + +
EmailRolEmpresaEstado
{u.email}{u.role}{u.tenantId ? empresaDe.get(u.tenantId) ?? '—' : '—'}{u.status} + {u.role !== 'admin' && ( +
+ + + +
+ )} +
+
+
+ ); +} +``` + +- [ ] **Step 3: Formulario de alta (Client Component)** + +```tsx +// src/app/admin/usuarios/CrearReformistaForm.tsx +'use client'; + +import { useActionState } from 'react'; +import { crearReformista } from './actions'; + +export function CrearReformistaForm() { + const [error, action, pending] = useActionState(crearReformista, null); + return ( +
+

Crear reformista

+ + + + + + {error &&

{error}

} + +
+ ); +} +``` + +- [ ] **Step 4: Verificar que compila** + +Run: `npx tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/admin/usuarios +git commit -m "Add gestión de usuarios en el admin" +``` + +--- + +### Task 17: Asignación de planes + +**Files:** +- Create: `src/app/admin/planes/actions.ts` +- Create: `src/app/admin/planes/page.tsx` + +- [ ] **Step 1: Actions de asignación** + +```typescript +// src/app/admin/planes/actions.ts +'use server'; + +import { revalidatePath } from 'next/cache'; +import { requireAdmin } from '@/lib/auth/current-user'; +import { assignPlan, setSubscriptionStatus } from '@/db/admin-queries'; +import { tenants } from '@/db/schema'; + +export async function asignarPlan(formData: FormData) { + await requireAdmin(); + const tenantId = String(formData.get('tenantId')); + const planId = String(formData.get('planId')); + const status = String(formData.get('status')) as (typeof tenants.subscriptionStatus.enumValues)[number]; + await assignPlan(tenantId, planId); + await setSubscriptionStatus(tenantId, status); + revalidatePath('/admin/planes'); +} +``` + +- [ ] **Step 2: Página (catálogo de planes + asignación por tenant)** + +```tsx +// src/app/admin/planes/page.tsx +import { listPlans, listTenants } from '@/db/admin-queries'; +import { asignarPlan } from './actions'; +import { formatEuros } from '@/lib/funnel'; + +export const dynamic = 'force-dynamic'; + +const ESTADOS = ['trial', 'activo', 'cancelado', 'vencido'] as const; + +export default async function PlanesPage() { + const [plans, tenants] = await Promise.all([listPlans(), listTenants()]); + const nombrePlan = new Map(plans.map((p) => [p.id, p.nombre])); + return ( +
+

Planes

+ +
+ {plans.map((p) => ( +
+
+

{p.nombre}

+ {formatEuros(p.precioMensual)}/mes +
+
    + {p.features.map((f) =>
  • · {f}
  • )} +
+
+ ))} +
+ +
+ + + + + + + {tenants.map((t) => ( + + + + + + + ))} + +
ReformistaPlan actualEstadoAsignar
{t.nombreEmpresa}{t.planId ? nombrePlan.get(t.planId) ?? '—' : '—'}{t.subscriptionStatus} +
+ + + + +
+
+
+
+ ); +} +``` + +- [ ] **Step 3: Verificar que compila** + +Run: `npx tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/admin/planes +git commit -m "Add asignación de planes y estado de suscripción" +``` + +--- + +## FASE 4 — Stripe stub + display de plan + +### Task 18: Cálculo de trial y badge de plan (puro, TDD) + +**Files:** +- Create: `src/lib/billing/plan.ts` +- Test: `tests/billing/plan.test.ts` + +- [ ] **Step 1: Escribir el test que falla** + +```typescript +// tests/billing/plan.test.ts +import { describe, it, expect } from 'vitest'; +import { trialDaysRemaining, formatPlanBadge } from '@/lib/billing/plan'; + +describe('plan', () => { + it('calcula días de trial restantes (redondeo hacia arriba, nunca negativo)', () => { + const now = new Date('2026-05-30T12:00:00Z'); + expect(trialDaysRemaining(new Date('2026-06-05T12:00:00Z'), now)).toBe(6); + expect(trialDaysRemaining(new Date('2026-05-30T18:00:00Z'), now)).toBe(1); + expect(trialDaysRemaining(new Date('2026-05-29T12:00:00Z'), now)).toBe(0); + expect(trialDaysRemaining(null, now)).toBe(0); + }); + + it('formatea el badge según estado', () => { + const now = new Date('2026-05-30T12:00:00Z'); + expect(formatPlanBadge('Pro', 'trial', new Date('2026-06-05T12:00:00Z'), now)) + .toBe('Plan Pro · trial, 6 días restantes'); + expect(formatPlanBadge('Pro', 'activo', null, now)).toBe('Plan Pro · activo'); + expect(formatPlanBadge(null, 'trial', new Date('2026-06-05T12:00:00Z'), now)) + .toBe('Sin plan · trial, 6 días restantes'); + }); +}); +``` + +- [ ] **Step 2: Ejecutar el test para verlo fallar** + +Run: `npm test -- tests/billing/plan.test.ts` +Expected: FAIL con "Cannot find module '@/lib/billing/plan'". + +- [ ] **Step 3: Implementación mínima** + +```typescript +// src/lib/billing/plan.ts +export function trialDaysRemaining(trialEndsAt: Date | null, now: Date = new Date()): number { + if (!trialEndsAt) return 0; + const ms = trialEndsAt.getTime() - now.getTime(); + if (ms <= 0) return 0; + return Math.ceil(ms / (24 * 60 * 60 * 1000)); +} + +export function formatPlanBadge( + planNombre: string | null, + status: string, + trialEndsAt: Date | null, + now: Date = new Date() +): string { + const plan = planNombre ? `Plan ${planNombre}` : 'Sin plan'; + if (status === 'trial') { + return `${plan} · trial, ${trialDaysRemaining(trialEndsAt, now)} días restantes`; + } + return `${plan} · ${status}`; +} +``` + +- [ ] **Step 4: Ejecutar el test para verlo pasar** + +Run: `npm test -- tests/billing/plan.test.ts` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/billing/plan.ts tests/billing/plan.test.ts +git commit -m "Add cálculo de trial y badge de plan" +``` + +--- + +### Task 19: Stub de Stripe + +**Files:** +- Create: `src/lib/billing/stripe.ts` + +- [ ] **Step 1: Interfaz no-op documentada** + +```typescript +// src/lib/billing/stripe.ts +// Stub de Stripe: sin cuenta activa, no se hacen llamadas reales (ver spec, "Fuera de alcance"). +// Reservado para cuando se conecte la pasarela; cualquier uso real debe lanzar para no fingir cobros. + +export const STRIPE_ENABLED = false; + +export async function createCustomer(_email: string): Promise { + throw new Error('Stripe no está configurado (stub).'); +} + +export async function createCheckoutSession(_args: { tenantId: string; planSlug: string }): Promise { + throw new Error('Stripe no está configurado (stub).'); +} +``` + +- [ ] **Step 2: Verificar que compila** + +Run: `npx tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/billing/stripe.ts +git commit -m "Add stub de Stripe (sin cobro real)" +``` + +--- + +### Task 20: Badge de plan en el panel del reformista + +**Files:** +- Modify: `src/app/panel/page.tsx` + +- [ ] **Step 1: Cargar el plan/estado del tenant y mostrar el badge** + +En `src/app/panel/page.tsx`, añade imports al inicio: + +```typescript +import { getCurrentTenantId } from '@/lib/auth/current-user'; +import { db } from '@/db'; +import { tenants, plans } from '@/db/schema'; +import { eq } from 'drizzle-orm'; +import { formatPlanBadge } from '@/lib/billing/plan'; +import { STRIPE_ENABLED } from '@/lib/billing/stripe'; +``` + +Dentro de `PanelPage`, tras resolver `filtro` y antes del `return`, carga el tenant y su plan: + +```typescript + const tenantId = await getCurrentTenantId(); + const [tenant] = await db.select().from(tenants).where(eq(tenants.id, tenantId)).limit(1); + const [plan] = tenant?.planId + ? await db.select().from(plans).where(eq(plans.id, tenant.planId)).limit(1) + : []; + const badge = formatPlanBadge(plan?.nombre ?? null, tenant?.subscriptionStatus ?? 'trial', tenant?.trialEndsAt ?? null); +``` + +Justo debajo del bloque del título (`
`, que termina antes de los filtros), inserta: + +```tsx +
+ {badge} + +
+``` + +- [ ] **Step 2: Verificar que compila** + +Run: `npx tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/panel/page.tsx +git commit -m "Mostrar badge de plan y botón de pago deshabilitado" +``` + +--- + +### Task 21: Extender cobertura de tests a los módulos puros + +**Files:** +- Modify: `vitest.config.ts:11-15` + +- [ ] **Step 1: Ampliar coverage.include a los módulos puros nuevos** + +```typescript + coverage: { + provider: 'v8', + include: [ + 'src/budget/**', + 'src/lib/auth/password.ts', + 'src/lib/auth/tokens.ts', + 'src/lib/auth/authz.ts', + 'src/lib/validation/signup.ts', + 'src/lib/billing/plan.ts', + ], + thresholds: { lines: 70, functions: 70, statements: 70, branches: 70 }, + }, +``` + +> No se incluyen `session.ts`, `current-user.ts`, `stripe.ts` ni las `*-queries.ts` porque dependen de DB/cookies y no se pueden cubrir sin un Postgres en CI (RNF-MAINT-01 aplica a la lógica pura). + +- [ ] **Step 2: Ejecutar la suite completa con cobertura** + +Run: `npm run test:coverage` +Expected: PASS en todos los tests; cobertura ≥70% en lines/functions/statements/branches sobre los módulos incluidos. + +- [ ] **Step 3: Verificar typecheck y lint globales** + +Run: `npx tsc --noEmit && npm run lint` +Expected: exit 0 en ambos. + +- [ ] **Step 4: Commit** + +```bash +git add vitest.config.ts +git commit -m "Extender cobertura de tests a auth, validación y billing" +``` + +--- + +## Verificación end-to-end (tras todas las tasks) + +Con un Postgres local y `.env.local` con `DATABASE_URL`: + +- [ ] `npm run db:migrate` aplica la migración nueva sin error. +- [ ] `SEED_FORCE=1 npm run db:seed` siembra planes + admin + owner. +- [ ] `npm run dev` y en el navegador: + - `/panel` sin sesión → redirige a `/login`. + - Login con `demo@reformas-ejemplo.es` / `DemoReformix2026!` → ve sus leads + badge "Plan Pro · trial, N días restantes". + - `/admin` con el reformista → redirige a `/panel`. + - Login con `admin@reformix.es` / `AdminReformix2026!` → ve `/admin`, crea un reformista, le asigna plan Starter y estado activo. + - El nuevo reformista entra y ve su panel vacío (0 leads), aislado del de Reformas Ejemplo. + - `/signup` crea una cuenta trial nueva y entra directo al panel. + - "Salir" cierra sesión y vuelve a `/login`. + +--- + +## Notas para producción (no parte de las tasks) + +El despliegue usa autodeploy Gitea→Dokploy y el `docker-entrypoint.sh` corre `db:migrate` → `db:seed` → `start`. La migración nueva se aplica sola en el próximo deploy. **El reseed con datos de auth solo ocurre si `SEED_FORCE=1` está en el env** (y hace TRUNCATE). Para poblar usuarios/planes en prod la primera vez: activar `SEED_FORCE=1`, desplegar, y **quitarlo después** para no borrar cuentas reales en cada deploy. + diff --git a/docs/superpowers/plans/2026-05-30-motor-presupuesto.md b/docs/superpowers/plans/2026-05-30-motor-presupuesto.md new file mode 100644 index 0000000..80f1d74 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-motor-presupuesto.md @@ -0,0 +1,1610 @@ +# Motor de Presupuesto Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Construir el motor de presupuesto de Reformix: una función pura que produce un desglose por partidas a partir de precios configurables + medidas mínimas, con panel para editar la tabla de precios (CRUD + import CSV), seed de catálogo demo, y el punto de integración que el funnel llamará para recalcular y guardar el presupuesto del lead. + +**Architecture:** Núcleo puro en `src/budget/` (sin DB ni red, cubierto por Vitest ≥70%) que recibe `(inputs, config, catalog)` y devuelve `BudgetResult`. La capa Drizzle persiste `pricing_config` + `catalog_items` por tenant y nuevos campos de inputs/snapshot en `leads`. El panel `/panel/precios` hace CRUD sobre config/catálogo. Una Server Action `recalcularPresupuesto(leadId)` une las piezas: lee inputs del lead, llama al motor, guarda total + snapshot. + +**Tech Stack:** Next.js 16 (App Router, Server Actions), TypeScript strict, Drizzle ORM + postgres.js, Tailwind v4, Vitest + @vitest/coverage-v8, zod. Todo dentro de `mvp/b2c`. Tenant único `reformas-ejemplo`. + +**Convención de dinero:** todo en **céntimos** (enteros), igual que el schema actual. + +**Comandos:** ejecutar siempre desde `mvp/b2c`. Tests: `npm run test`. Cobertura: `npm run test:coverage`. + +--- + +## Estructura de ficheros + +**Crear:** +- `mvp/b2c/vitest.config.ts` — config de Vitest (alias `@`, cobertura sobre `src/budget/**`). +- `mvp/b2c/src/budget/types.ts` — tipos del dominio (sin lógica). +- `mvp/b2c/src/budget/labels.ts` — orden y etiquetas de partidas. +- `mvp/b2c/src/budget/derive.ts` — `deriveCantidades()` (medidas → cantidades). +- `mvp/b2c/src/budget/resolve.ts` — `resolvePrecioUnitario()` (lookup en catálogo). +- `mvp/b2c/src/budget/compute.ts` — `computeBudget()` (orquesta todo). +- `mvp/b2c/src/budget/csv.ts` — `parseCatalogCsv()` (parser + validación zod). +- `mvp/b2c/src/budget/index.ts` — re-exports públicos. +- `mvp/b2c/tests/budget/derive.test.ts` +- `mvp/b2c/tests/budget/resolve.test.ts` +- `mvp/b2c/tests/budget/compute.test.ts` +- `mvp/b2c/tests/budget/csv.test.ts` +- `mvp/b2c/src/db/pricing-queries.ts` — `getPricingConfig()`, `getCatalog()` (mapean fila DB → tipos del motor). +- `mvp/b2c/src/app/panel/precios/page.tsx` — UI del panel de precios. +- `mvp/b2c/src/app/panel/precios/actions.ts` — Server Actions CRUD + CSV. + +**Modificar:** +- `mvp/b2c/package.json` — deps (`zod`) + devDeps (`vitest`, `@vitest/coverage-v8`) + scripts `test`, `test:coverage`. +- `mvp/b2c/src/db/schema.ts` — enums + tablas `pricing_config`, `catalog_items` + campos nuevos en `leads`. +- `mvp/b2c/src/db/seed.ts` — sembrar `pricing_config` + catálogo demo + inputs demo en un lead. +- `mvp/b2c/src/app/panel/actions.ts` — añadir `recalcularPresupuesto(leadId)`. +- `mvp/b2c/src/app/panel/[id]/page.tsx` — mostrar el desglose del presupuesto. +- `mvp/b2c/src/app/panel/layout.tsx` — enlace de navegación a `/panel/precios`. + +--- + +## Task 1: Montar Vitest + +**Files:** +- Modify: `mvp/b2c/package.json` +- Create: `mvp/b2c/vitest.config.ts` +- Create: `mvp/b2c/tests/smoke.test.ts` + +- [ ] **Step 1: Instalar dependencias de test** + +Run (desde `mvp/b2c`): +```bash +npm install -D vitest @vitest/coverage-v8 +npm install zod +``` +Expected: `package.json` actualizado, sin errores. + +- [ ] **Step 2: Añadir scripts a package.json** + +En `mvp/b2c/package.json`, dentro de `"scripts"`, añadir tras `"db:seed"`: +```json + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" +``` + +- [ ] **Step 3: Crear vitest.config.ts** + +```ts +import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; + +export default defineConfig({ + resolve: { + alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, + }, + test: { + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['src/budget/**'], + thresholds: { lines: 70, functions: 70, statements: 70, branches: 70 }, + }, + }, +}); +``` + +- [ ] **Step 4: Crear un test de humo** + +`mvp/b2c/tests/smoke.test.ts`: +```ts +import { describe, it, expect } from 'vitest'; + +describe('vitest setup', () => { + it('runs', () => { + expect(1 + 1).toBe(2); + }); +}); +``` + +- [ ] **Step 5: Ejecutar y verificar que pasa** + +Run: `npm run test` +Expected: 1 passed. + +- [ ] **Step 6: Commit** + +```bash +git add mvp/b2c/package.json mvp/b2c/package-lock.json mvp/b2c/vitest.config.ts mvp/b2c/tests/smoke.test.ts +git commit -m "chore: set up vitest and add zod" +``` + +--- + +## Task 2: Tipos del dominio y etiquetas de partidas + +**Files:** +- Create: `mvp/b2c/src/budget/types.ts` +- Create: `mvp/b2c/src/budget/labels.ts` + +Sin tests propios (son solo tipos/constantes; se ejercitan en Tasks 3-5). + +- [ ] **Step 1: Crear types.ts** + +```ts +export type Calidad = 'basica' | 'media' | 'premium'; +export type Unidad = 'm2' | 'ml' | 'ud'; +export type CategoriaMaterial = 'suelo' | 'pared' | 'pintura' | 'mobiliario'; +export type TipoReforma = 'cocina' | 'bano' | 'salon' | 'comedor' | 'integral' | 'otro'; + +export type PartidaKey = + | 'demolicion' + | 'alicatado' + | 'fontaneria' + | 'electricidad' + | 'carpinteria' + | 'mano_de_obra' + | 'extras' + | 'licencia'; + +export type ManoObraKey = 'demolicion' | 'fontaneria' | 'electricidad' | 'mano_de_obra'; + +export interface CatalogItem { + id: string; + categoria: CategoriaMaterial; + nombre: string; + calidad: Calidad; + precioUnit: number; // céntimos por unidad + unidad: Unidad; + descriptorRender: string; + esDefault: boolean; + sku: string; +} + +export interface PricingConfig { + alturaTechoDefault: number; // metros + factorZona: Record; // provincia -> multiplicador + manoObra: Record; // céntimos por m² de suelo +} + +export interface BudgetInputs { + tipoReforma: TipoReforma; + m2Suelo: number | null; + alturaTecho: number | null; + calidadGlobal: Calidad; + estructural: boolean; + provincia: string | null; + materialSelections: Partial>; // categoria -> catalogItemId +} + +export interface PartidaResult { + key: PartidaKey; + label: string; + importe: number; // céntimos (base, antes de factor zona) +} + +export interface BudgetResult { + partidas: PartidaResult[]; + subtotal: number; // céntimos + factorZona: number; + total: number; // céntimos = round(subtotal * factorZona) + rango: { min: number; max: number }; // céntimos + confianza: 'baja' | 'media' | 'alta'; + materialesRender: string[]; // descriptores para el prompt del render + avisos: string[]; +} +``` + +- [ ] **Step 2: Crear labels.ts** + +```ts +import type { PartidaKey } from './types'; + +export const PARTIDA_ORDER: PartidaKey[] = [ + 'demolicion', + 'alicatado', + 'fontaneria', + 'electricidad', + 'carpinteria', + 'mano_de_obra', + 'extras', + 'licencia', +]; + +export const PARTIDA_LABEL: Record = { + demolicion: 'Demolición', + alicatado: 'Alicatado y solado', + fontaneria: 'Fontanería', + electricidad: 'Electricidad', + carpinteria: 'Carpintería y mobiliario', + mano_de_obra: 'Mano de obra', + extras: 'Pintura y extras', + licencia: 'Licencia + Proyecto técnico', +}; +``` + +- [ ] **Step 3: Commit** + +```bash +git add mvp/b2c/src/budget/types.ts mvp/b2c/src/budget/labels.ts +git commit -m "feat: add budget domain types and partida labels" +``` + +--- + +## Task 3: Derivar cantidades desde las medidas + +**Files:** +- Create: `mvp/b2c/src/budget/derive.ts` +- Test: `mvp/b2c/tests/budget/derive.test.ts` + +- [ ] **Step 1: Escribir el test que falla** + +`mvp/b2c/tests/budget/derive.test.ts`: +```ts +import { describe, it, expect } from 'vitest'; +import { deriveCantidades } from '@/budget/derive'; +import type { BudgetInputs, PricingConfig } from '@/budget/types'; + +const config: PricingConfig = { + alturaTechoDefault: 2.5, + factorZona: {}, + manoObra: { demolicion: 0, fontaneria: 0, electricidad: 0, mano_de_obra: 0 }, +}; + +function inputs(partial: Partial): BudgetInputs { + return { + tipoReforma: 'cocina', + m2Suelo: null, + alturaTecho: null, + calidadGlobal: 'media', + estructural: false, + provincia: null, + materialSelections: {}, + ...partial, + }; +} + +describe('deriveCantidades', () => { + it('usa m² aportados y deriva perímetro y paredes con números limpios', () => { + // m2Suelo=16 -> sqrt=4 -> perimetro=16 -> pared=16*2.5=40 -> mobiliario=16*0.5=8 (cocina) + const c = deriveCantidades(inputs({ m2Suelo: 16 }), config); + expect(c.m2Suelo).toBe(16); + expect(c.perimetro).toBe(16); + expect(c.m2Pared).toBe(40); + expect(c.mlMobiliario).toBe(8); + expect(c.alturaTecho).toBe(2.5); + }); + + it('cae a la mediana por tipo cuando no hay m²', () => { + const c = deriveCantidades(inputs({ tipoReforma: 'bano', m2Suelo: null }), config); + expect(c.m2Suelo).toBe(5); // mediana baño + }); + + it('usa la altura aportada por encima del default', () => { + const c = deriveCantidades(inputs({ m2Suelo: 16, alturaTecho: 3 }), config); + expect(c.alturaTecho).toBe(3); + expect(c.m2Pared).toBe(48); // 16 * 3 + }); + + it('no calcula mobiliario para tipos sin cocina/baño', () => { + const c = deriveCantidades(inputs({ tipoReforma: 'salon', m2Suelo: 16 }), config); + expect(c.mlMobiliario).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Ejecutar para verificar que falla** + +Run: `npm run test -- derive` +Expected: FAIL ("Cannot find module '@/budget/derive'" o similar). + +- [ ] **Step 3: Implementar derive.ts** + +```ts +import type { BudgetInputs, PricingConfig, TipoReforma } from './types'; + +export interface Cantidades { + m2Suelo: number; + m2Pared: number; + mlMobiliario: number; + perimetro: number; + alturaTecho: number; +} + +const M2_MEDIANA: Record = { + cocina: 10, + bano: 5, + salon: 20, + comedor: 16, + integral: 70, + otro: 12, +}; + +// Metros lineales de mobiliario por metro de perímetro. Solo cocina/baño. +const FACTOR_MOBILIARIO: Partial> = { + cocina: 0.5, + bano: 0.3, +}; + +export function deriveCantidades(inputs: BudgetInputs, config: PricingConfig): Cantidades { + const m2Suelo = + inputs.m2Suelo != null && inputs.m2Suelo > 0 + ? inputs.m2Suelo + : M2_MEDIANA[inputs.tipoReforma]; + const alturaTecho = + inputs.alturaTecho != null && inputs.alturaTecho > 0 + ? inputs.alturaTecho + : config.alturaTechoDefault; + const perimetro = 4 * Math.sqrt(m2Suelo); + const m2Pared = perimetro * alturaTecho; + const mlMobiliario = perimetro * (FACTOR_MOBILIARIO[inputs.tipoReforma] ?? 0); + return { m2Suelo, m2Pared, mlMobiliario, perimetro, alturaTecho }; +} +``` + +- [ ] **Step 4: Ejecutar para verificar que pasa** + +Run: `npm run test -- derive` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add mvp/b2c/src/budget/derive.ts mvp/b2c/tests/budget/derive.test.ts +git commit -m "feat: derive cantidades from minimal measurements" +``` + +--- + +## Task 4: Resolver el precio unitario desde el catálogo + +**Files:** +- Create: `mvp/b2c/src/budget/resolve.ts` +- Test: `mvp/b2c/tests/budget/resolve.test.ts` + +- [ ] **Step 1: Escribir el test que falla** + +`mvp/b2c/tests/budget/resolve.test.ts`: +```ts +import { describe, it, expect } from 'vitest'; +import { resolvePrecioUnitario } from '@/budget/resolve'; +import type { CatalogItem } from '@/budget/types'; + +const catalog: CatalogItem[] = [ + { id: 's-media', categoria: 'suelo', nombre: 'Cerámico medio', calidad: 'media', precioUnit: 2800, unidad: 'm2', descriptorRender: 'suelo cerámico gris', esDefault: true, sku: 'SUE-M' }, + { id: 's-premium', categoria: 'suelo', nombre: 'Porcelánico roble', calidad: 'premium', precioUnit: 4500, unidad: 'm2', descriptorRender: 'porcelánico símil roble', esDefault: true, sku: 'SUE-P' }, +]; + +describe('resolvePrecioUnitario', () => { + it('devuelve el default de la calidad cuando no hay selección', () => { + const { item } = resolvePrecioUnitario('suelo', 'media', catalog, {}); + expect(item?.id).toBe('s-media'); + }); + + it('prioriza la selección exacta sobre la calidad global', () => { + const { item } = resolvePrecioUnitario('suelo', 'media', catalog, { suelo: 's-premium' }); + expect(item?.id).toBe('s-premium'); + }); + + it('devuelve null si no hay default para esa calidad ni selección', () => { + const { item } = resolvePrecioUnitario('pared', 'media', catalog, {}); + expect(item).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Ejecutar para verificar que falla** + +Run: `npm run test -- resolve` +Expected: FAIL ("Cannot find module '@/budget/resolve'"). + +- [ ] **Step 3: Implementar resolve.ts** + +```ts +import type { Calidad, CategoriaMaterial, CatalogItem } from './types'; + +export function resolvePrecioUnitario( + categoria: CategoriaMaterial, + calidad: Calidad, + catalog: CatalogItem[], + selections: Partial>, +): { item: CatalogItem | null } { + const selectedId = selections[categoria]; + if (selectedId) { + const selected = catalog.find((c) => c.id === selectedId); + if (selected) return { item: selected }; + } + const def = catalog.find( + (c) => c.categoria === categoria && c.calidad === calidad && c.esDefault, + ); + return { item: def ?? null }; +} +``` + +- [ ] **Step 4: Ejecutar para verificar que pasa** + +Run: `npm run test -- resolve` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add mvp/b2c/src/budget/resolve.ts mvp/b2c/tests/budget/resolve.test.ts +git commit -m "feat: resolve unit price from catalog with selection override" +``` + +--- + +## Task 5: computeBudget (núcleo) + +**Files:** +- Create: `mvp/b2c/src/budget/compute.ts` +- Create: `mvp/b2c/src/budget/index.ts` +- Test: `mvp/b2c/tests/budget/compute.test.ts` + +- [ ] **Step 1: Escribir el test que falla** + +`mvp/b2c/tests/budget/compute.test.ts`: +```ts +import { describe, it, expect } from 'vitest'; +import { computeBudget } from '@/budget/compute'; +import type { BudgetInputs, CatalogItem, PricingConfig } from '@/budget/types'; + +const config: PricingConfig = { + alturaTechoDefault: 2.5, + factorZona: { Madrid: 1.1 }, + manoObra: { demolicion: 1500, fontaneria: 1200, electricidad: 1000, mano_de_obra: 3000 }, +}; + +const catalog: CatalogItem[] = [ + { id: 'suelo-m', categoria: 'suelo', nombre: 'Cerámico', calidad: 'media', precioUnit: 2800, unidad: 'm2', descriptorRender: 'suelo cerámico gris', esDefault: true, sku: 'SUE-M' }, + { id: 'pared-m', categoria: 'pared', nombre: 'Azulejo', calidad: 'media', precioUnit: 2400, unidad: 'm2', descriptorRender: 'azulejo blanco', esDefault: true, sku: 'PAR-M' }, + { id: 'pintura-m', categoria: 'pintura', nombre: 'Plástica', calidad: 'media', precioUnit: 800, unidad: 'm2', descriptorRender: 'pintura blanca mate', esDefault: true, sku: 'PIN-M' }, + { id: 'mob-m', categoria: 'mobiliario', nombre: 'Muebles cocina', calidad: 'media', precioUnit: 32000, unidad: 'ml', descriptorRender: 'muebles laminado roble', esDefault: true, sku: 'MOB-M' }, + { id: 'suelo-p', categoria: 'suelo', nombre: 'Porcelánico', calidad: 'premium', precioUnit: 4500, unidad: 'm2', descriptorRender: 'porcelánico símil roble', esDefault: true, sku: 'SUE-P' }, +]; + +function inputs(partial: Partial): BudgetInputs { + return { + tipoReforma: 'cocina', + m2Suelo: 16, + alturaTecho: null, + calidadGlobal: 'media', + estructural: false, + provincia: 'Madrid', + materialSelections: {}, + ...partial, + }; +} + +describe('computeBudget', () => { + it('calcula partidas, subtotal, factor zona y total con números conocidos', () => { + const r = computeBudget(inputs({}), config, catalog); + // m2Suelo=16 -> perimetro=16, pared=40, mobiliario=8 + const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe])); + expect(byKey.demolicion).toBe(24000); // 16*1500 + expect(byKey.alicatado).toBe(140800); // 16*2800 + 40*2400 + expect(byKey.fontaneria).toBe(19200); // 16*1200 + expect(byKey.electricidad).toBe(16000); // 16*1000 + expect(byKey.carpinteria).toBe(256000); // 8*32000 + expect(byKey.mano_de_obra).toBe(48000); // 16*3000 + expect(byKey.extras).toBe(32000); // 40*800 + expect(byKey.licencia).toBeUndefined(); + expect(r.subtotal).toBe(536000); + expect(r.factorZona).toBe(1.1); + expect(r.total).toBe(589600); // round(536000 * 1.1) + }); + + it('confianza media (±15%) con m² pero sin selección exacta', () => { + const r = computeBudget(inputs({}), config, catalog); + expect(r.confianza).toBe('media'); + expect(r.rango.min).toBe(501160); // round(589600*0.85) + expect(r.rango.max).toBe(678040); // round(589600*1.15) + }); + + it('confianza alta (±10%) con m² y selección exacta', () => { + const r = computeBudget(inputs({ materialSelections: { suelo: 'suelo-p' } }), config, catalog); + expect(r.confianza).toBe('alta'); + expect(r.materialesRender).toContain('porcelánico símil roble'); + }); + + it('confianza baja (±25%) sin m² ni selección', () => { + const r = computeBudget(inputs({ m2Suelo: null }), config, catalog); + expect(r.confianza).toBe('baja'); + }); + + it('añade partida de licencia y amplía el máximo si hay cambio estructural', () => { + const base = computeBudget(inputs({}), config, catalog); + const r = computeBudget(inputs({ estructural: true }), config, catalog); + const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe])); + expect(byKey.licencia).toBe(30000); // 300€ mínimo + expect(r.rango.max).toBe(base.rango.max + 33000 + 120000); + // +33000 = round((536000+30000)*1.1)-589600 efecto licencia en total; +120000 = banda licencia + }); + + it('emite aviso cuando falta precio de una categoría', () => { + const sinPintura = catalog.filter((c) => c.categoria !== 'pintura'); + const r = computeBudget(inputs({}), config, sinPintura); + expect(r.avisos.some((a) => a.includes('pintura'))).toBe(true); + }); +}); +``` + +> Nota sobre el test de licencia: con estructural el subtotal pasa a 566000, total = round(566000*1.1) = 622600 (= base.total + 33000). La banda media (±15%) da max = round(622600*1.15) = 715990, y se le suma la banda de licencia (120000). El test lo expresa como `base.rango.max (678040) + 33000 + 120000 = 831040`. Verifica que el cálculo del implementador cuadra; si la implementación de abajo da otro número exacto por redondeo, ajusta el valor esperado al de la implementación de referencia (no cambies la fórmula). + +- [ ] **Step 2: Ejecutar para verificar que falla** + +Run: `npm run test -- compute` +Expected: FAIL ("Cannot find module '@/budget/compute'"). + +- [ ] **Step 3: Implementar compute.ts** + +```ts +import { deriveCantidades } from './derive'; +import { resolvePrecioUnitario } from './resolve'; +import { PARTIDA_LABEL, PARTIDA_ORDER } from './labels'; +import type { + BudgetInputs, + BudgetResult, + CategoriaMaterial, + CatalogItem, + PartidaKey, + PricingConfig, +} from './types'; + +const LICENCIA_MIN = 30000; // 300 € +const LICENCIA_MAX = 150000; // 1.500 € + +// A qué partida contribuye el material de cada categoría. +const MATERIAL_PARTIDA: Record = { + suelo: 'alicatado', + pared: 'alicatado', + pintura: 'extras', + mobiliario: 'carpinteria', +}; + +const CATEGORIAS: CategoriaMaterial[] = ['suelo', 'pared', 'pintura', 'mobiliario']; + +export function computeBudget( + inputs: BudgetInputs, + config: PricingConfig, + catalog: CatalogItem[], +): BudgetResult { + const cant = deriveCantidades(inputs, config); + const avisos: string[] = []; + const materialesRender: string[] = []; + + const importes: Record = { + demolicion: 0, + alicatado: 0, + fontaneria: 0, + electricidad: 0, + carpinteria: 0, + mano_de_obra: 0, + extras: 0, + licencia: 0, + }; + + const cantidadPorCategoria: Record = { + suelo: cant.m2Suelo, + pared: cant.m2Pared, + pintura: cant.m2Pared, + mobiliario: cant.mlMobiliario, + }; + + for (const categoria of CATEGORIAS) { + const cantidad = cantidadPorCategoria[categoria]; + if (cantidad <= 0) continue; + const { item } = resolvePrecioUnitario( + categoria, + inputs.calidadGlobal, + catalog, + inputs.materialSelections, + ); + if (!item) { + avisos.push(`Sin precio para ${categoria} (calidad ${inputs.calidadGlobal})`); + continue; + } + importes[MATERIAL_PARTIDA[categoria]] += Math.round(cantidad * item.precioUnit); + if (item.descriptorRender) materialesRender.push(item.descriptorRender); + } + + importes.demolicion += Math.round(cant.m2Suelo * config.manoObra.demolicion); + importes.fontaneria += Math.round(cant.m2Suelo * config.manoObra.fontaneria); + importes.electricidad += Math.round(cant.m2Suelo * config.manoObra.electricidad); + importes.mano_de_obra += Math.round(cant.m2Suelo * config.manoObra.mano_de_obra); + + if (inputs.estructural) importes.licencia += LICENCIA_MIN; + + const partidas = PARTIDA_ORDER.filter((k) => importes[k] > 0).map((k) => ({ + key: k, + label: PARTIDA_LABEL[k], + importe: importes[k], + })); + + const subtotal = partidas.reduce((s, p) => s + p.importe, 0); + const factorZona = (inputs.provincia && config.factorZona[inputs.provincia]) || 1; + const total = Math.round(subtotal * factorZona); + + const hasExact = Object.keys(inputs.materialSelections).length > 0; + const hasM2 = inputs.m2Suelo != null && inputs.m2Suelo > 0; + let confianza: BudgetResult['confianza']; + let band: number; + if (hasM2 && hasExact) { + confianza = 'alta'; + band = 0.1; + } else if (hasM2 || hasExact) { + confianza = 'media'; + band = 0.15; + } else { + confianza = 'baja'; + band = 0.25; + } + + const rango = { + min: Math.round(total * (1 - band)), + max: Math.round(total * (1 + band)) + (inputs.estructural ? LICENCIA_MAX - LICENCIA_MIN : 0), + }; + + return { partidas, subtotal, factorZona, total, rango, confianza, materialesRender, avisos }; +} +``` + +- [ ] **Step 4: Crear index.ts (re-exports)** + +```ts +export * from './types'; +export { PARTIDA_LABEL, PARTIDA_ORDER } from './labels'; +export { deriveCantidades } from './derive'; +export { resolvePrecioUnitario } from './resolve'; +export { computeBudget } from './compute'; +export { parseCatalogCsv } from './csv'; +``` + +> `./csv` se crea en la Task 6. Si ejecutas esta task de forma aislada, comenta esa línea hasta completar la Task 6. + +- [ ] **Step 5: Ejecutar para verificar que pasa** + +Run: `npm run test -- compute` +Expected: PASS. Si el test de licencia falla por el valor esperado exacto, ajusta SOLO ese número al que produce esta implementación (ver nota del Step 1). + +- [ ] **Step 6: Commit** + +```bash +git add mvp/b2c/src/budget/compute.ts mvp/b2c/src/budget/index.ts mvp/b2c/tests/budget/compute.test.ts +git commit -m "feat: implement computeBudget with partidas, zona factor, licencia and range" +``` + +--- + +## Task 6: Parser de catálogo CSV + +**Files:** +- Create: `mvp/b2c/src/budget/csv.ts` +- Test: `mvp/b2c/tests/budget/csv.test.ts` + +Formato CSV (primera línea = cabecera): `categoria,nombre,calidad,precio,unidad,descriptor_render,sku` +`precio` viene en **euros** (decimal con punto) y se convierte a céntimos. + +- [ ] **Step 1: Escribir el test que falla** + +`mvp/b2c/tests/budget/csv.test.ts`: +```ts +import { describe, it, expect } from 'vitest'; +import { parseCatalogCsv } from '@/budget/csv'; + +const HEADER = 'categoria,nombre,calidad,precio,unidad,descriptor_render,sku'; + +describe('parseCatalogCsv', () => { + it('parsea filas válidas y convierte precio a céntimos', () => { + const csv = [ + HEADER, + 'suelo,Cerámico gris,media,28.00,m2,suelo cerámico gris,SUE-M', + 'mobiliario,Muebles cocina,premium,550,ml,muebles laminado roble,MOB-P', + ].join('\n'); + const { rows, errors } = parseCatalogCsv(csv); + expect(errors).toHaveLength(0); + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ + categoria: 'suelo', + calidad: 'media', + precioUnit: 2800, + unidad: 'm2', + sku: 'SUE-M', + }); + expect(rows[1].precioUnit).toBe(55000); + }); + + it('reporta errores por fila sin abortar las válidas', () => { + const csv = [ + HEADER, + 'suelo,Bueno,media,28,m2,desc,SUE-M', + 'inventada,Malo,media,10,m2,desc,X', // categoria inválida + 'pared,Sin precio,media,abc,m2,desc,PAR-M', // precio no numérico + ].join('\n'); + const { rows, errors } = parseCatalogCsv(csv); + expect(rows).toHaveLength(1); + expect(errors).toHaveLength(2); + expect(errors[0].line).toBe(3); // 1-indexed incluyendo cabecera + expect(errors[1].line).toBe(4); + }); + + it('devuelve error global si falta la cabecera esperada', () => { + const { rows, errors } = parseCatalogCsv('a,b,c\n1,2,3'); + expect(rows).toHaveLength(0); + expect(errors[0].message).toMatch(/cabecera/i); + }); +}); +``` + +- [ ] **Step 2: Ejecutar para verificar que falla** + +Run: `npm run test -- csv` +Expected: FAIL ("Cannot find module '@/budget/csv'"). + +- [ ] **Step 3: Implementar csv.ts** + +```ts +import { z } from 'zod'; +import type { CategoriaMaterial, Calidad, Unidad } from './types'; + +export interface ParsedCatalogRow { + categoria: CategoriaMaterial; + nombre: string; + calidad: Calidad; + precioUnit: number; // céntimos + unidad: Unidad; + descriptorRender: string; + sku: string; +} + +export interface CsvError { + line: number; // 1-indexed (la cabecera es la línea 1) + message: string; +} + +const HEADER = ['categoria', 'nombre', 'calidad', 'precio', 'unidad', 'descriptor_render', 'sku']; + +const rowSchema = z.object({ + categoria: z.enum(['suelo', 'pared', 'pintura', 'mobiliario']), + nombre: z.string().min(1), + calidad: z.enum(['basica', 'media', 'premium']), + precio: z + .string() + .transform((s) => Number(s)) + .refine((n) => Number.isFinite(n) && n > 0, 'precio inválido'), + unidad: z.enum(['m2', 'ml', 'ud']), + descriptor_render: z.string(), + sku: z.string().min(1), +}); + +export function parseCatalogCsv(text: string): { rows: ParsedCatalogRow[]; errors: CsvError[] } { + const lines = text + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l.length > 0); + + if (lines.length === 0) { + return { rows: [], errors: [{ line: 1, message: 'CSV vacío' }] }; + } + + const header = lines[0].split(',').map((h) => h.trim()); + if (HEADER.some((h, i) => header[i] !== h)) { + return { + rows: [], + errors: [{ line: 1, message: `Cabecera inválida. Esperada: ${HEADER.join(',')}` }], + }; + } + + const rows: ParsedCatalogRow[] = []; + const errors: CsvError[] = []; + + for (let i = 1; i < lines.length; i++) { + const cells = lines[i].split(',').map((c) => c.trim()); + const record = Object.fromEntries(HEADER.map((h, idx) => [h, cells[idx] ?? ''])); + const parsed = rowSchema.safeParse(record); + if (!parsed.success) { + errors.push({ line: i + 1, message: parsed.error.issues[0]?.message ?? 'fila inválida' }); + continue; + } + const d = parsed.data; + rows.push({ + categoria: d.categoria, + nombre: d.nombre, + calidad: d.calidad, + precioUnit: Math.round(d.precio * 100), + unidad: d.unidad, + descriptorRender: d.descriptor_render, + sku: d.sku, + }); + } + + return { rows, errors }; +} +``` + +- [ ] **Step 4: Ejecutar para verificar que pasa** + +Run: `npm run test -- csv` +Expected: PASS (3 tests). + +- [ ] **Step 5: Verificar cobertura del motor** + +Run: `npm run test:coverage` +Expected: PASS y cobertura de `src/budget/**` ≥ 70% en lines/functions/statements/branches. + +- [ ] **Step 6: Commit** + +```bash +git add mvp/b2c/src/budget/csv.ts mvp/b2c/tests/budget/csv.test.ts +git commit -m "feat: add catalog CSV parser with per-row validation" +``` + +--- + +## Task 7: Extender el schema Drizzle + +**Files:** +- Modify: `mvp/b2c/src/db/schema.ts` + +Sin test unitario (es definición de schema; se valida con la migración en Task 8). + +- [ ] **Step 1: Añadir imports faltantes** + +En `mvp/b2c/src/db/schema.ts`, en el bloque de import de `drizzle-orm/pg-core`, añadir `doublePrecision` y `uniqueIndex` a la lista existente: + +```ts +import { + pgTable, + pgEnum, + uuid, + text, + integer, + boolean, + numeric, + timestamp, + jsonb, + index, + doublePrecision, + uniqueIndex, +} from 'drizzle-orm/pg-core'; +``` + +- [ ] **Step 2: Añadir los enums nuevos** + +Tras el bloque del enum `tipoReforma` (antes de la tabla `tenants`), añadir: + +```ts +export const calidad = pgEnum('calidad', ['basica', 'media', 'premium']); + +export const categoriaMaterial = pgEnum('categoria_material', [ + 'suelo', + 'pared', + 'pintura', + 'mobiliario', +]); + +export const unidadMedida = pgEnum('unidad_medida', ['m2', 'ml', 'ud']); +``` + +- [ ] **Step 3: Añadir campos nuevos a la tabla `leads`** + +Dentro del objeto de columnas de `leads`, tras la línea `notas: text('notas'),`, añadir (antes del cierre `}` del objeto de columnas): + +```ts + // Inputs del motor de presupuesto (capturados de menos a más en el funnel) + m2Suelo: doublePrecision('m2_suelo'), + alturaTecho: doublePrecision('altura_techo'), + calidadGlobal: calidad('calidad_global'), + estructural: boolean('estructural').notNull().default(false), + materialSelections: jsonb('material_selections') + .$type>() + .notNull() + .default({}), + desgloseSnapshot: jsonb('desglose_snapshot'), +``` + +- [ ] **Step 4: Añadir las tablas pricing_config y catalog_items** + +Tras la definición de `precisionHistory` (antes del bloque de `export type`), añadir: + +```ts +// Configuración de precios del reformista (1 fila por tenant). RF-D-07. +export const pricingConfig = pgTable('pricing_config', { + id: uuid('id').primaryKey().defaultRandom(), + tenantId: uuid('tenant_id') + .notNull() + .references(() => tenants.id, { onDelete: 'cascade' }) + .unique(), + alturaTechoDefault: doublePrecision('altura_techo_default').notNull().default(2.5), + factorZona: jsonb('factor_zona').$type>().notNull().default({}), + manoObra: jsonb('mano_obra').$type>().notNull().default({}), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); + +// Catálogo de materiales del reformista. Importable por CSV. +export const catalogItems = pgTable( + 'catalog_items', + { + id: uuid('id').primaryKey().defaultRandom(), + tenantId: uuid('tenant_id') + .notNull() + .references(() => tenants.id, { onDelete: 'cascade' }), + categoria: categoriaMaterial('categoria').notNull(), + nombre: text('nombre').notNull(), + calidad: calidad('calidad').notNull(), + precioUnit: integer('precio_unit').notNull(), // céntimos por unidad + unidad: unidadMedida('unidad').notNull(), + descriptorRender: text('descriptor_render').notNull().default(''), + esDefault: boolean('es_default').notNull().default(false), + sku: text('sku').notNull(), + }, + (table) => [ + index('catalog_tenant_idx').on(table.tenantId), + uniqueIndex('catalog_tenant_sku_idx').on(table.tenantId, table.sku), + ] +); +``` + +- [ ] **Step 5: Añadir los tipos inferidos** + +Al final del fichero, tras `export type PrecisionHistory = ...`, añadir: + +```ts +export type PricingConfigRow = typeof pricingConfig.$inferSelect; +export type CatalogItemRow = typeof catalogItems.$inferSelect; +export type NewCatalogItem = typeof catalogItems.$inferInsert; +``` + +- [ ] **Step 6: Verificar que compila el typecheck del schema** + +Run: `npx tsc --noEmit` +Expected: sin errores en `src/db/schema.ts` (puede haber errores preexistentes en otros ficheros; ignóralos si no son del schema). + +- [ ] **Step 7: Commit** + +```bash +git add mvp/b2c/src/db/schema.ts +git commit -m "feat: add pricing_config, catalog_items and budget input fields to schema" +``` + +--- + +## Task 8: Generar migración y sembrar config + catálogo demo + +**Files:** +- Create: `mvp/b2c/drizzle/0001_*.sql` (lo genera drizzle-kit) +- Modify: `mvp/b2c/src/db/seed.ts` + +Requiere `DATABASE_URL` en `mvp/b2c/.env.local` apuntando a un Postgres accesible. + +- [ ] **Step 1: Generar la migración** + +Run (desde `mvp/b2c`): `npm run db:generate` +Expected: nuevo fichero en `drizzle/0001_*.sql` con `CREATE TABLE pricing_config`, `CREATE TABLE catalog_items`, los nuevos `CREATE TYPE` y los `ALTER TABLE leads ADD COLUMN`. + +- [ ] **Step 2: Añadir el sembrado de precios y catálogo al seed** + +En `mvp/b2c/src/db/seed.ts`, justo antes de `await client.end();` (al final de `main()`), añadir este bloque autocontenido: + +```ts + // --- Precios + catálogo demo (motor de presupuesto) --- + const [tenantRow] = await db + .select() + .from(schema.tenants) + .where(eq(schema.tenants.slug, 'reformas-ejemplo')) + .limit(1); + + if (tenantRow) { + await db.delete(schema.catalogItems).where(eq(schema.catalogItems.tenantId, tenantRow.id)); + await db.delete(schema.pricingConfig).where(eq(schema.pricingConfig.tenantId, tenantRow.id)); + + await db.insert(schema.pricingConfig).values({ + tenantId: tenantRow.id, + alturaTechoDefault: 2.5, + factorZona: { Madrid: 1.1, Barcelona: 1.15, Valencia: 1.0, Sevilla: 0.95 }, + manoObra: { demolicion: 1800, fontaneria: 2200, electricidad: 1600, mano_de_obra: 3500 }, + }); + + const cat = ( + categoria: 'suelo' | 'pared' | 'pintura' | 'mobiliario', + nombre: string, + calidad: 'basica' | 'media' | 'premium', + precioEuros: number, + unidad: 'm2' | 'ml' | 'ud', + descriptorRender: string, + sku: string, + ) => ({ + tenantId: tenantRow.id, + categoria, + nombre, + calidad, + precioUnit: Math.round(precioEuros * 100), + unidad, + descriptorRender, + esDefault: true, + sku, + }); + + await db.insert(schema.catalogItems).values([ + cat('suelo', 'Gres cerámico básico', 'basica', 16, 'm2', 'suelo gres beige liso', 'SUE-B'), + cat('suelo', 'Porcelánico símil madera', 'media', 28, 'm2', 'porcelánico símil roble claro', 'SUE-M'), + cat('suelo', 'Porcelánico gran formato', 'premium', 48, 'm2', 'porcelánico gran formato gris piedra', 'SUE-P'), + cat('pared', 'Azulejo blanco brillo', 'basica', 14, 'm2', 'azulejo blanco brillo 20x20', 'PAR-B'), + cat('pared', 'Azulejo rectificado', 'media', 24, 'm2', 'azulejo rectificado blanco mate 30x60', 'PAR-M'), + cat('pared', 'Porcelánico decorativo', 'premium', 42, 'm2', 'porcelánico decorativo símil mármol', 'PAR-P'), + cat('pintura', 'Plástica mate', 'basica', 6, 'm2', 'pintura plástica blanca mate', 'PIN-B'), + cat('pintura', 'Plástica lavable', 'media', 9, 'm2', 'pintura lavable blanco roto', 'PIN-M'), + cat('pintura', 'Esmalte premium', 'premium', 14, 'm2', 'esmalte al agua acabado seda gris perla', 'PIN-P'), + cat('mobiliario', 'Muebles melamina', 'basica', 180, 'ml', 'muebles cocina melamina blanca', 'MOB-B'), + cat('mobiliario', 'Muebles laminado', 'media', 320, 'ml', 'muebles cocina laminado roble con tirador integrado', 'MOB-M'), + cat('mobiliario', 'Muebles lacado', 'premium', 550, 'ml', 'muebles cocina lacado mate antracita y encimera porcelánica', 'MOB-P'), + ]); + + // Inputs demo en un lead ya avanzado para poder recalcular su presupuesto. + await db + .update(schema.leads) + .set({ m2Suelo: 12, calidadGlobal: 'media', estructural: false }) + .where(eq(schema.leads.email, 'roberto.salas@example.com')); + } +``` + +> Nota: el bloque es idempotente por sí mismo (borra config+catálogo del tenant y reinserta). Usa `schema.catalogItems`, `schema.pricingConfig`, `schema.leads` ya importados vía `import * as schema`. `eq` ya está importado en seed.ts. + +- [ ] **Step 3: Aplicar migración y seed** + +Run (desde `mvp/b2c`): +```bash +npm run db:migrate +SEED_FORCE=1 npm run db:seed +``` +Expected: migración aplicada; seed imprime su log sin errores. + +- [ ] **Step 4: Verificar datos** + +Run (desde `mvp/b2c`): +```bash +node -e "const p=require('postgres')(process.env.DATABASE_URL,{prepare:false});p\`select count(*) from catalog_items\`.then(r=>{console.log(r);return p.end()})" +``` +Expected: count = 12. (Si prefieres, usa `npm run db:studio` para inspeccionar visualmente.) + +- [ ] **Step 5: Commit** + +```bash +git add mvp/b2c/drizzle mvp/b2c/src/db/seed.ts +git commit -m "feat: migrate and seed pricing config + demo catalog" +``` + +--- + +## Task 9: Queries para mapear DB → tipos del motor + +**Files:** +- Create: `mvp/b2c/src/db/pricing-queries.ts` + +Sin test unitario (capa de acceso a DB; se valida en el panel y en la integración). + +- [ ] **Step 1: Implementar pricing-queries.ts** + +```ts +import { eq } from 'drizzle-orm'; +import { db } from './index'; +import { pricingConfig, catalogItems, tenants } from './schema'; +import { TENANT_SLUG } from '@/lib/funnel'; +import type { PricingConfig, CatalogItem, ManoObraKey } from '@/budget/types'; + +async function getTenantId(): Promise { + const [tenant] = await db.select().from(tenants).where(eq(tenants.slug, TENANT_SLUG)).limit(1); + if (!tenant) throw new Error(`Tenant "${TENANT_SLUG}" no existe. ¿Has corrido npm run db:seed?`); + return tenant.id; +} + +const MANO_OBRA_DEFAULT: Record = { + demolicion: 0, + fontaneria: 0, + electricidad: 0, + mano_de_obra: 0, +}; + +export async function getPricingConfig(): Promise { + const tenantId = await getTenantId(); + const [row] = await db + .select() + .from(pricingConfig) + .where(eq(pricingConfig.tenantId, tenantId)) + .limit(1); + + if (!row) { + return { alturaTechoDefault: 2.5, factorZona: {}, manoObra: { ...MANO_OBRA_DEFAULT } }; + } + return { + alturaTechoDefault: row.alturaTechoDefault, + factorZona: row.factorZona, + manoObra: { ...MANO_OBRA_DEFAULT, ...(row.manoObra as Record) }, + }; +} + +export async function getCatalog(): Promise { + const tenantId = await getTenantId(); + const rows = await db.select().from(catalogItems).where(eq(catalogItems.tenantId, tenantId)); + return rows.map((r) => ({ + id: r.id, + categoria: r.categoria, + nombre: r.nombre, + calidad: r.calidad, + precioUnit: r.precioUnit, + unidad: r.unidad, + descriptorRender: r.descriptorRender, + esDefault: r.esDefault, + sku: r.sku, + })); +} + +export { getTenantId }; +``` + +- [ ] **Step 2: Verificar typecheck** + +Run: `npx tsc --noEmit` +Expected: sin errores nuevos en `pricing-queries.ts`. + +- [ ] **Step 3: Commit** + +```bash +git add mvp/b2c/src/db/pricing-queries.ts +git commit -m "feat: add queries mapping pricing config and catalog to engine types" +``` + +--- + +## Task 10: Panel de precios (CRUD + import CSV) + +**Files:** +- Create: `mvp/b2c/src/app/panel/precios/actions.ts` +- Create: `mvp/b2c/src/app/panel/precios/page.tsx` +- Modify: `mvp/b2c/src/app/panel/layout.tsx` + +- [ ] **Step 1: Crear las Server Actions** + +`mvp/b2c/src/app/panel/precios/actions.ts`: +```ts +'use server'; + +import { and, eq } from 'drizzle-orm'; +import { revalidatePath } from 'next/cache'; +import { db } from '@/db'; +import { catalogItems, pricingConfig } from '@/db/schema'; +import { getTenantId } from '@/db/pricing-queries'; +import { parseCatalogCsv } from '@/budget/csv'; + +export async function crearMaterial(formData: FormData) { + const tenantId = await getTenantId(); + await db.insert(catalogItems).values({ + tenantId, + categoria: formData.get('categoria') as 'suelo' | 'pared' | 'pintura' | 'mobiliario', + nombre: String(formData.get('nombre') ?? ''), + calidad: formData.get('calidad') as 'basica' | 'media' | 'premium', + precioUnit: Math.round(Number(formData.get('precioEuros') ?? 0) * 100), + unidad: formData.get('unidad') as 'm2' | 'ml' | 'ud', + descriptorRender: String(formData.get('descriptorRender') ?? ''), + esDefault: formData.get('esDefault') === 'on', + sku: String(formData.get('sku') ?? ''), + }); + revalidatePath('/panel/precios'); +} + +export async function actualizarPrecio(formData: FormData) { + const tenantId = await getTenantId(); + const id = String(formData.get('id') ?? ''); + await db + .update(catalogItems) + .set({ precioUnit: Math.round(Number(formData.get('precioEuros') ?? 0) * 100) }) + .where(and(eq(catalogItems.id, id), eq(catalogItems.tenantId, tenantId))); + revalidatePath('/panel/precios'); +} + +export async function borrarMaterial(formData: FormData) { + const tenantId = await getTenantId(); + const id = String(formData.get('id') ?? ''); + await db.delete(catalogItems).where(and(eq(catalogItems.id, id), eq(catalogItems.tenantId, tenantId))); + revalidatePath('/panel/precios'); +} + +export async function actualizarConfig(formData: FormData) { + const tenantId = await getTenantId(); + await db + .update(pricingConfig) + .set({ + alturaTechoDefault: Number(formData.get('alturaTechoDefault') ?? 2.5), + manoObra: { + demolicion: Math.round(Number(formData.get('mo_demolicion') ?? 0) * 100), + fontaneria: Math.round(Number(formData.get('mo_fontaneria') ?? 0) * 100), + electricidad: Math.round(Number(formData.get('mo_electricidad') ?? 0) * 100), + mano_de_obra: Math.round(Number(formData.get('mo_mano_de_obra') ?? 0) * 100), + }, + updatedAt: new Date(), + }) + .where(eq(pricingConfig.tenantId, tenantId)); + revalidatePath('/panel/precios'); +} + +export type ImportResult = { ok: boolean; inserted: number; errors: { line: number; message: string }[] }; + +export async function importarCatalogoCsv(_prev: ImportResult | null, formData: FormData): Promise { + const tenantId = await getTenantId(); + const csv = String(formData.get('csv') ?? ''); + const { rows, errors } = parseCatalogCsv(csv); + if (errors.length > 0) return { ok: false, inserted: 0, errors }; + + for (const r of rows) { + await db + .insert(catalogItems) + .values({ tenantId, ...r }) + .onConflictDoUpdate({ + target: [catalogItems.tenantId, catalogItems.sku], + set: { + categoria: r.categoria, + nombre: r.nombre, + calidad: r.calidad, + precioUnit: r.precioUnit, + unidad: r.unidad, + descriptorRender: r.descriptorRender, + }, + }); + } + revalidatePath('/panel/precios'); + return { ok: true, inserted: rows.length, errors: [] }; +} +``` + +- [ ] **Step 2: Crear la página del panel de precios** + +`mvp/b2c/src/app/panel/precios/page.tsx`: +```tsx +import { getPricingConfig, getCatalog } from '@/db/pricing-queries'; +import { formatEuros } from '@/lib/funnel'; +import { + crearMaterial, + actualizarPrecio, + borrarMaterial, + actualizarConfig, +} from './actions'; + +export const dynamic = 'force-dynamic'; + +const CATEGORIAS = ['suelo', 'pared', 'pintura', 'mobiliario'] as const; +const CATEGORIA_LABEL: Record<(typeof CATEGORIAS)[number], string> = { + suelo: 'Suelos', + pared: 'Paredes / alicatado', + pintura: 'Pinturas', + mobiliario: 'Mobiliario', +}; + +export default async function PreciosPage() { + const [config, catalog] = await Promise.all([getPricingConfig(), getCatalog()]); + + return ( +
+
+

Tabla de precios

+

+ Define los precios unitarios y la mano de obra. El motor calcula el presupuesto a + partir de estos valores y las medidas del lead. +

+
+ + {/* Config general */} +
+

Configuración general

+
+ + {(['demolicion', 'fontaneria', 'electricidad', 'mano_de_obra'] as const).map((k) => ( + + ))} + +
+
+ + {/* Catálogo por categoría */} + {CATEGORIAS.map((categoria) => { + const items = catalog.filter((c) => c.categoria === categoria); + return ( +
+

{CATEGORIA_LABEL[categoria]}

+
+ {items.length === 0 &&

Sin materiales.

} + {items.map((item) => ( +
+ {item.nombre} + {item.calidad} + {item.unidad} + {item.esDefault && ( + default + )} +
+ + + + +
+
+ + +
+
+ ))} +
+ +
+ + + + + + + + + +
+
+ ); + })} + + {/* Import CSV */} +
+

Importar catálogo (CSV)

+

+ Cabecera: categoria,nombre,calidad,precio,unidad,descriptor_render,sku. El + precio en euros. Actualiza por SKU. +

+
void}> +