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. +