# Plan 2A: Schema + Auth para Horux Despachos — 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:** Evolucionar el schema de BD central y tenant para soportar el modelo de despachos (verticalProfile, dbMode, entidades gestionadas, contribuyentes, carteras), agregar roles de despacho (supervisor, cliente), refactorear el migration runner para tracking por scope, y crear un endpoint de signup básico para despachos.

**Architecture:** Se evoluciona el modelo `Tenant` existente (no se renombra — un despacho ES un tenant con campos adicionales). Se agregan 5 tenant migrations SQL (tracking table + entidades + carteras + contribuyentes + cliente_accesos). El migration runner se extiende para soportar tabla de tracking (`tenant_migrations`). Se agregan tipos nuevos a `@horux/shared`.

**Tech Stack:** Prisma 5.22, PostgreSQL 16, TypeScript 5, Express 4.21, pnpm workspaces.

**Validation:** `pnpm --filter @horux/api typecheck` (57 pre-existing errors baseline — verify no NEW errors). `pnpm --filter @horux/shared typecheck` (0 errors baseline).

**Git:** Commits locales, sin push. Un commit por task.

**Pre-existing codebase context (Plan 2A engineer MUST know):**
- Prisma schema: `apps/api/prisma/schema.prisma` — modelos Tenant, User, TenantMembership, Rol, FielCredential, Subscription, etc.
- Current roles table (Rol): id=1 owner, id=2 contador, id=3 visor, id=7 cfo, id=8 auxiliar.
- Tenant migrations: `apps/api/src/migrations/tenant/001-005.sql` — flat numbered, applied lazily by `TenantConnectionManager.getPool()` via `migrate()` in `config/tenant-migrations.ts`.
- Auth JWT payload: `{ userId, email, role, tenantId, platformRoles?, tokenVersion? }` from `@horux/shared`.
- Config env: `apps/api/src/config/env.ts` — Zod-validated, includes DATABASE_URL, JWT_SECRET, FIEL_ENCRYPTION_KEY.
- Imports in apps/api use `.js` extension (NodeNext module resolution).

---

## File Structure

**New files:**
- `apps/api/prisma/migrations/YYYYMMDD_despacho_fields/migration.sql` — Prisma migration (auto-generated)
- `apps/api/src/migrations/tenant/006_tenant_migrations_tracking.sql` — tracking table for scope-based migrations
- `apps/api/src/migrations/tenant/007_entidades_gestionadas.sql` — core: base entity table
- `apps/api/src/migrations/tenant/008_carteras.sql` — core: portfolios + assignments
- `apps/api/src/migrations/tenant/009_cliente_accesos.sql` — core: client-viewer access
- `apps/api/src/migrations/tenant/010_contribuyentes.sql` — vertical-contable: taxpayer subtype
- `packages/shared/src/types/despacho.ts` — DespachoRole, VerticalProfile, DbMode types
- `apps/api/src/controllers/despacho.controller.ts` — signup endpoint
- `apps/api/src/services/despacho.service.ts` — signup business logic
- `apps/api/src/routes/despacho.routes.ts` — route mounting

**Modified files:**
- `apps/api/prisma/schema.prisma` — add fields to Tenant, add enums
- `apps/api/prisma/seed.ts` — add 'supervisor' and 'cliente' roles
- `apps/api/src/config/tenant-migrations.ts` — support tracking table
- `apps/api/src/app.ts` — mount despacho routes
- `packages/shared/src/types/auth.ts` — add DespachoRole to exports
- `packages/shared/src/index.ts` — re-export despacho types

---

## Tasks

### Task 1: Prisma migration — add despacho fields to Tenant

**Files:**
- Modify: `apps/api/prisma/schema.prisma`
- Create: auto-generated migration via `prisma migrate dev`

- [ ] **Step 1: Add new enums and fields to Prisma schema**

Open `apps/api/prisma/schema.prisma` and add the following:

After the existing `enum Plan { ... }`:
```prisma
enum VerticalProfile {
  CONTABLE
  JURIDICO
  ARQUITECTURA
}

enum DbMode {
  BYO
  MANAGED
}
```

In the `model Tenant { ... }`, add AFTER the `telefono` field (before the relations block):
```prisma
  // === Despacho fields (Plan 2A) ===
  verticalProfile      VerticalProfile? @map("vertical_profile")
  dbMode               DbMode?          @map("db_mode")
  dbConnectionEnc      String?          @map("db_connection_enc")
  dbConnectionIv       String?          @map("db_connection_iv")
  dbSchemaVersion      Int              @default(0) @map("db_schema_version")
  connectorTokenEnc    String?          @map("connector_token_enc")
  connectorTunnelHostname String?       @map("connector_tunnel_hostname")
  connectorLastSeen    DateTime?        @map("connector_last_seen")
  connectorVersion     String?          @map("connector_version") @db.VarChar(20)
```

- [ ] **Step 2: Generate and apply Prisma migration**

Run:
```bash
cd apps/api && npx prisma migrate dev --name despacho_fields
```

Expected: migration SQL generated in `prisma/migrations/YYYYMMDD_despacho_fields/`. Since all new fields are nullable or have defaults, this is safe for existing data.

If the command fails because there's no DB connection, create the migration without applying:
```bash
cd apps/api && npx prisma migrate dev --name despacho_fields --create-only
```

- [ ] **Step 3: Generate Prisma client**

Run:
```bash
cd apps/api && npx prisma generate
```

- [ ] **Step 4: Verify typecheck**

Run: `pnpm --filter @horux/api typecheck`
Expected: same 57 pre-existing errors, no new ones.

- [ ] **Step 5: Commit**

```bash
git add apps/api/prisma/
git commit -m "feat(schema): add despacho fields to Tenant model (verticalProfile, dbMode, connector)"
```

---

### Task 2: Seed new roles (supervisor, cliente)

**Files:**
- Modify: `apps/api/prisma/seed.ts`

- [ ] **Step 1: Read current seed.ts to understand the roles seeding pattern**

Open `apps/api/prisma/seed.ts` and find where roles are upserted. The current roles are:
```
id=1: owner, id=2: contador, id=3: visor, id=7: cfo, id=8: auxiliar
```

- [ ] **Step 2: Add supervisor and cliente roles to the seed**

Add to the roles upsert section:
```typescript
await prisma.rol.upsert({
  where: { nombre: 'supervisor' },
  update: {},
  create: { id: 9, nombre: 'supervisor', descripcion: 'Supervisor de despacho — titular de RFCs, crea carteras' },
});

await prisma.rol.upsert({
  where: { nombre: 'cliente' },
  update: {},
  create: { id: 10, nombre: 'cliente', descripcion: 'Cliente visor externo — acceso read-only a sus RFCs' },
});
```

- [ ] **Step 3: Run seed (if DB is available)**

Run:
```bash
cd apps/api && npx prisma db seed
```

If no DB connection, skip — the seed will run at next deploy. The migration and seed are idempotent.

- [ ] **Step 4: Commit**

```bash
git add apps/api/prisma/seed.ts
git commit -m "feat(seed): add supervisor and cliente roles for despachos"
```

---

### Task 3: Add despacho types to @horux/shared

**Files:**
- Create: `packages/shared/src/types/despacho.ts`
- Modify: `packages/shared/src/index.ts` (or wherever types are re-exported)

- [ ] **Step 1: Create despacho types file**

Create `packages/shared/src/types/despacho.ts`:

```typescript
export type DespachoRole = 'owner' | 'supervisor' | 'auxiliar' | 'cliente';

export type VerticalProfile = 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';

export type DbMode = 'BYO' | 'MANAGED';

export interface DespachoInfo {
  id: string;
  nombre: string;
  rfc: string;
  verticalProfile: VerticalProfile;
  dbMode: DbMode | null;
  plan: string;
}

export interface DespachoSignupRequest {
  despacho: {
    nombre: string;
    rfc: string;
    regimenFiscal?: string;
    codigoPostal?: string;
    verticalProfile: VerticalProfile;
  };
  owner: {
    nombre: string;
    email: string;
    password: string;
  };
}

export interface ContribuyenteInfo {
  id: string;
  rfc: string;
  razonSocial: string;
  regimenFiscal: string;
  codigoPostal?: string;
  supervisorUserId?: string;
  active: boolean;
}
```

- [ ] **Step 2: Find and update the barrel export**

Read `packages/shared/src/index.ts` to see how types are exported. Add:

```typescript
export * from './types/despacho';
```

If the barrel uses a different pattern (e.g., explicit re-exports), follow that pattern.

- [ ] **Step 3: Verify typecheck**

Run: `pnpm --filter @horux/shared typecheck`
Expected: 0 errors.

- [ ] **Step 4: Commit**

```bash
git add packages/shared/
git commit -m "feat(shared): add DespachoRole, VerticalProfile, DbMode types"
```

---

### Task 4: Tenant migration — tracking table + entidades_gestionadas

**Files:**
- Create: `apps/api/src/migrations/tenant/006_tenant_migrations_tracking.sql`
- Create: `apps/api/src/migrations/tenant/007_entidades_gestionadas.sql`

- [ ] **Step 1: Create migration 006 — tracking table**

Create `apps/api/src/migrations/tenant/006_tenant_migrations_tracking.sql`:

```sql
-- Tracking table for scope-based migrations.
-- Allows checking which migrations have been applied and which are pending.
-- For now, all existing migrations (001-005) are considered "legacy" scope
-- and are tracked by the existing file-based runner. This table tracks
-- only NEW migrations going forward (007+).
CREATE TABLE IF NOT EXISTS tenant_migrations (
  scope      varchar(50) NOT NULL,
  version    int NOT NULL,
  name       varchar(255),
  applied_at timestamptz DEFAULT now(),
  PRIMARY KEY (scope, version)
);

-- Mark 001-005 as already applied under "legacy" scope
-- so the runner doesn't try to re-apply them.
INSERT INTO tenant_migrations (scope, version, name)
VALUES
  ('legacy', 1, '001_initial_schema'),
  ('legacy', 2, '002_create_opiniones_cumplimiento'),
  ('legacy', 3, '003_create_declaraciones_provisionales'),
  ('legacy', 4, '004_declaraciones_liga_pago_pdf'),
  ('legacy', 5, '005_create_constancias_situacion_fiscal'),
  ('legacy', 6, '006_tenant_migrations_tracking')
ON CONFLICT (scope, version) DO NOTHING;
```

- [ ] **Step 2: Create migration 007 — entidades_gestionadas**

Create `apps/api/src/migrations/tenant/007_entidades_gestionadas.sql`:

```sql
-- Core table: base entity managed by the despacho.
-- Subtyped by vertical (e.g., contribuyentes for CONTABLE).
-- Carteras and client access operate on this table (vertical-agnostic).
CREATE TABLE IF NOT EXISTS entidades_gestionadas (
  id                 uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tipo               varchar(20) NOT NULL,
  nombre             text NOT NULL,
  identificador      text,
  supervisor_user_id uuid,
  active             boolean DEFAULT true,
  created_at         timestamptz DEFAULT now(),
  updated_at         timestamptz DEFAULT now()
);

CREATE INDEX IF NOT EXISTS ix_entidades_supervisor ON entidades_gestionadas(supervisor_user_id);
CREATE INDEX IF NOT EXISTS ix_entidades_tipo ON entidades_gestionadas(tipo, active);
CREATE INDEX IF NOT EXISTS ix_entidades_identificador ON entidades_gestionadas(identificador);

INSERT INTO tenant_migrations (scope, version, name)
VALUES ('core', 7, '007_entidades_gestionadas')
ON CONFLICT (scope, version) DO NOTHING;
```

- [ ] **Step 3: Verify SQL syntax**

Read both files back to confirm no typos. The SQL should be idempotent (`IF NOT EXISTS`, `ON CONFLICT DO NOTHING`).

- [ ] **Step 4: Commit**

```bash
git add apps/api/src/migrations/tenant/006_tenant_migrations_tracking.sql apps/api/src/migrations/tenant/007_entidades_gestionadas.sql
git commit -m "feat(migrations): add tenant_migrations tracking + entidades_gestionadas table"
```

---

### Task 5: Tenant migrations — carteras + cliente_accesos + contribuyentes

**Files:**
- Create: `apps/api/src/migrations/tenant/008_carteras.sql`
- Create: `apps/api/src/migrations/tenant/009_cliente_accesos.sql`
- Create: `apps/api/src/migrations/tenant/010_contribuyentes.sql`

- [ ] **Step 1: Create migration 008 — carteras**

Create `apps/api/src/migrations/tenant/008_carteras.sql`:

```sql
-- Core: supervisor portfolios. A supervisor groups entities into carteras
-- and assigns auxiliares to them. Cascading: if supervisor loses an entity,
-- it's removed from all their carteras automatically (via JOIN, not trigger).
CREATE TABLE IF NOT EXISTS carteras (
  id                 uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  supervisor_user_id uuid NOT NULL,
  nombre             text NOT NULL,
  descripcion        text,
  created_at         timestamptz DEFAULT now()
);

CREATE INDEX IF NOT EXISTS ix_carteras_supervisor ON carteras(supervisor_user_id);

CREATE TABLE IF NOT EXISTS cartera_entidades (
  cartera_id uuid NOT NULL REFERENCES carteras(id) ON DELETE CASCADE,
  entidad_id uuid NOT NULL REFERENCES entidades_gestionadas(id) ON DELETE CASCADE,
  added_at   timestamptz DEFAULT now(),
  PRIMARY KEY (cartera_id, entidad_id)
);

CREATE TABLE IF NOT EXISTS cartera_auxiliares (
  cartera_id       uuid NOT NULL REFERENCES carteras(id) ON DELETE CASCADE,
  auxiliar_user_id uuid NOT NULL,
  added_at         timestamptz DEFAULT now(),
  PRIMARY KEY (cartera_id, auxiliar_user_id)
);

INSERT INTO tenant_migrations (scope, version, name)
VALUES ('core', 8, '008_carteras')
ON CONFLICT (scope, version) DO NOTHING;
```

- [ ] **Step 2: Create migration 009 — cliente_accesos**

Create `apps/api/src/migrations/tenant/009_cliente_accesos.sql`:

```sql
-- Core: direct access grants for external client-viewers.
-- A client user can see specific entities (not via carteras).
CREATE TABLE IF NOT EXISTS cliente_accesos (
  user_id    uuid NOT NULL,
  entidad_id uuid NOT NULL REFERENCES entidades_gestionadas(id) ON DELETE CASCADE,
  granted_at timestamptz DEFAULT now(),
  PRIMARY KEY (user_id, entidad_id)
);

INSERT INTO tenant_migrations (scope, version, name)
VALUES ('core', 9, '009_cliente_accesos')
ON CONFLICT (scope, version) DO NOTHING;
```

- [ ] **Step 3: Create migration 010 — contribuyentes (vertical contable)**

Create `apps/api/src/migrations/tenant/010_contribuyentes.sql`:

```sql
-- Vertical CONTABLE: taxpayer subtype of entidades_gestionadas.
-- Uses single-PK inheritance: contribuyentes.entidad_id = entidades_gestionadas.id.
-- CFDI and other fiscal tables will FK to this table (via entidad_id, not a separate id).
CREATE TABLE IF NOT EXISTS contribuyentes (
  entidad_id      uuid PRIMARY KEY REFERENCES entidades_gestionadas(id) ON DELETE CASCADE,
  rfc             varchar(13) NOT NULL UNIQUE,
  regimen_fiscal  varchar(3),
  codigo_postal   varchar(5),
  domicilio       jsonb
);

CREATE INDEX IF NOT EXISTS ix_contribuyentes_rfc ON contribuyentes(rfc);

INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 10, '010_contribuyentes')
ON CONFLICT (scope, version) DO NOTHING;
```

- [ ] **Step 4: Commit**

```bash
git add apps/api/src/migrations/tenant/008_carteras.sql apps/api/src/migrations/tenant/009_cliente_accesos.sql apps/api/src/migrations/tenant/010_contribuyentes.sql
git commit -m "feat(migrations): add carteras, cliente_accesos, contribuyentes tables"
```

---

### Task 6: Despacho signup service + controller + route

**Files:**
- Create: `apps/api/src/services/despacho.service.ts`
- Create: `apps/api/src/controllers/despacho.controller.ts`
- Create: `apps/api/src/routes/despacho.routes.ts`
- Modify: `apps/api/src/app.ts` (mount route)

- [ ] **Step 1: Create despacho service**

Create `apps/api/src/services/despacho.service.ts`:

```typescript
import { prisma, tenantDb } from '../config/database.js';
import { hashPassword } from '../auth/passwords.js';
import { generateAccessToken, generateRefreshToken } from '../auth/tokens.js';
import type { DespachoSignupRequest, VerticalProfile } from '@horux/shared';
import type { JWTPayload, Role } from '@horux/shared';

export async function signupDespacho(data: DespachoSignupRequest) {
  const { despacho, owner } = data;

  // Validate uniqueness
  const existingTenant = await prisma.tenant.findUnique({ where: { rfc: despacho.rfc } });
  if (existingTenant) {
    throw new Error('Ya existe una empresa registrada con este RFC');
  }

  const existingUser = await prisma.user.findUnique({ where: { email: owner.email } });
  if (existingUser) {
    throw new Error('Ya existe un usuario con este email');
  }

  const passwordHash = await hashPassword(owner.password);

  // Create tenant + user + membership in transaction
  const result = await prisma.$transaction(async (tx) => {
    // 1. Create tenant as despacho
    const databaseName = `horux_${despacho.rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;

    const tenant = await tx.tenant.create({
      data: {
        nombre: despacho.nombre,
        rfc: despacho.rfc.toUpperCase(),
        plan: 'starter',
        databaseName,
        cfdiLimit: 0,
        usersLimit: 3,
        verticalProfile: despacho.verticalProfile as any,
        dbMode: 'MANAGED' as any,
        dbSchemaVersion: 0,
        trialEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
        codigoPostal: despacho.codigoPostal,
      },
    });

    // 2. Create user
    const user = await tx.user.create({
      data: {
        email: owner.email.toLowerCase(),
        passwordHash,
        nombre: owner.nombre,
        lastTenantId: tenant.id,
      },
    });

    // 3. Create membership as owner
    const ownerRole = await tx.rol.findUnique({ where: { nombre: 'owner' } });
    if (!ownerRole) throw new Error('Rol owner no encontrado en BD');

    await tx.tenantMembership.create({
      data: {
        userId: user.id,
        tenantId: tenant.id,
        rolId: ownerRole.id,
        isOwner: true,
      },
    });

    return { tenant, user };
  });

  // 4. Provision tenant database (outside transaction — creates actual DB)
  try {
    await tenantDb.provisionDatabase(despacho.rfc);
  } catch (err: any) {
    // If DB provisioning fails, delete the tenant (rollback)
    await prisma.tenant.delete({ where: { id: result.tenant.id } });
    await prisma.user.delete({ where: { id: result.user.id } });
    throw new Error(`Error al crear base de datos del despacho: ${err.message}`);
  }

  // 5. Generate JWT pair
  const payload: Omit<JWTPayload, 'iat' | 'exp'> = {
    userId: result.user.id,
    email: result.user.email,
    role: 'owner' as Role,
    tenantId: result.tenant.id,
    tokenVersion: 0,
  };

  const accessToken = generateAccessToken(payload);
  const refreshToken = generateRefreshToken(payload);

  // 6. Store refresh token
  await prisma.refreshToken.create({
    data: {
      userId: result.user.id,
      token: refreshToken,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
    },
  });

  return {
    accessToken,
    refreshToken,
    user: {
      id: result.user.id,
      email: result.user.email,
      nombre: result.user.nombre,
      role: 'owner' as Role,
      tenantId: result.tenant.id,
      tenantName: result.tenant.nombre,
      tenantRfc: result.tenant.rfc,
      plan: result.tenant.plan,
      tenants: [{
        id: result.tenant.id,
        nombre: result.tenant.nombre,
        rfc: result.tenant.rfc,
        plan: result.tenant.plan,
        role: 'owner' as Role,
        isOwner: true,
      }],
    },
  };
}
```

- [ ] **Step 2: Create despacho controller**

Create `apps/api/src/controllers/despacho.controller.ts`:

```typescript
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { signupDespacho } from '../services/despacho.service.js';
import { AppError } from '../middlewares/error.middleware.js';

const signupSchema = z.object({
  despacho: z.object({
    nombre: z.string().min(2, 'Nombre del despacho requerido'),
    rfc: z.string().regex(/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/i, 'RFC inválido'),
    regimenFiscal: z.string().optional(),
    codigoPostal: z.string().regex(/^\d{5}$/, 'Código postal inválido').optional(),
    verticalProfile: z.enum(['CONTABLE', 'JURIDICO', 'ARQUITECTURA']),
  }),
  owner: z.object({
    nombre: z.string().min(2, 'Nombre del owner requerido'),
    email: z.string().email('Email inválido'),
    password: z.string().min(10, 'La contraseña debe tener al menos 10 caracteres'),
  }),
});

export async function signup(req: Request, res: Response, next: NextFunction) {
  try {
    const data = signupSchema.parse(req.body);
    const result = await signupDespacho(data);
    return res.status(201).json(result);
  } catch (error: any) {
    if (error instanceof z.ZodError) {
      return next(new AppError(400, error.errors[0].message));
    }
    if (error.message?.includes('Ya existe')) {
      return next(new AppError(409, error.message));
    }
    return next(error);
  }
}
```

- [ ] **Step 3: Create despacho routes**

Create `apps/api/src/routes/despacho.routes.ts`:

```typescript
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { signup } from '../controllers/despacho.controller.js';

const router = Router();

const signupLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 5,
  message: { message: 'Demasiados intentos de registro. Intenta en una hora.' },
});

router.post('/signup', signupLimiter, signup);

export default router;
```

- [ ] **Step 4: Mount route in app.ts**

Open `apps/api/src/app.ts`. Find the block where routes are mounted (look for `app.use('/api/auth'`). Add:

```typescript
import despachoRoutes from './routes/despacho.routes.js';
// ... in the routes section:
app.use('/api/despachos', despachoRoutes);
```

- [ ] **Step 5: Verify typecheck**

Run: `pnpm --filter @horux/api typecheck`
Expected: same pre-existing errors, no new ones. If there are new errors from the despacho files, fix them (likely import paths or type mismatches with Prisma generated types).

Common issue: Prisma might not know about `VerticalProfile` and `DbMode` enums yet if the migration wasn't applied. If typecheck fails on `verticalProfile: despacho.verticalProfile as any`, the `as any` cast handles it. If stricter typing is needed, verify Prisma client was regenerated (Step 3 of Task 1).

- [ ] **Step 6: Commit**

```bash
git add apps/api/src/services/despacho.service.ts apps/api/src/controllers/despacho.controller.ts apps/api/src/routes/despacho.routes.ts apps/api/src/app.ts
git commit -m "feat(api): add POST /api/despachos/signup endpoint"
```

---

### Task 7: Validation + smoke test

**Files:** None (verification only)

- [ ] **Step 1: Verify all packages typecheck**

Run:
```bash
pnpm --filter @horux/shared typecheck
pnpm --filter @horux/core typecheck
pnpm --filter @horux/shared-ui typecheck
pnpm --filter @horux/api typecheck
```

Expected: shared/core/shared-ui = 0 errors. api = same pre-existing errors only.

- [ ] **Step 2: Verify migration files exist and are numbered correctly**

Run:
```bash
ls -la apps/api/src/migrations/tenant/
```

Expected: files 001-010 in order. Verify 006-010 are our new ones.

- [ ] **Step 3: Verify Prisma schema has new fields**

Run:
```bash
grep -n "verticalProfile\|dbMode\|dbSchemaVersion\|connectorTokenEnc" apps/api/prisma/schema.prisma
```

Expected: all 4 fields present.

- [ ] **Step 4: Verify commit history**

Run:
```bash
git log --oneline -10
```

Expected: 6 new commits from this plan on top of the Plan 1 refactor commits.

- [ ] **Step 5: Start dev server and test signup endpoint (MANUAL)**

Run: `pnpm dev`

Test with curl (or user in browser):
```bash
curl -X POST http://localhost:4000/api/despachos/signup \
  -H "Content-Type: application/json" \
  -d '{
    "despacho": {
      "nombre": "Despacho Test",
      "rfc": "DTE250101AAA",
      "verticalProfile": "CONTABLE"
    },
    "owner": {
      "nombre": "Test Owner",
      "email": "test@despacho.com",
      "password": "testpassword123"
    }
  }'
```

Expected: 201 with `{ accessToken, refreshToken, user: { ... } }`.

If no DB connection, this step is deferred. The typecheck validation is sufficient for the plan.

- [ ] **Step 6: Final commit if any fixes were needed**

```bash
git add -A && git status
# Only commit if there are changes
git commit -m "fix: Plan 2A validation fixes" || true
```

---

## Self-Review

### Spec coverage (vs spec §3-§5, §11, §15-Phase1)

| Spec requirement | Task | Status |
|------------------|------|--------|
| Tenant evolves to support despacho (verticalProfile, dbMode, connector fields) | Task 1 | ✅ |
| New roles: supervisor, cliente | Task 2 | ✅ |
| Shared types: DespachoRole, VerticalProfile, DbMode, DespachoSignupRequest, ContribuyenteInfo | Task 3 | ✅ |
| Tenant migration: tenant_migrations tracking table | Task 4 | ✅ |
| Tenant migration: entidades_gestionadas (core) | Task 4 | ✅ |
| Tenant migration: carteras + cartera_entidades + cartera_auxiliares (core) | Task 5 | ✅ |
| Tenant migration: cliente_accesos (core) | Task 5 | ✅ |
| Tenant migration: contribuyentes (vertical-contable, single-PK inheritance) | Task 5 | ✅ |
| Signup endpoint: POST /despachos/signup | Task 6 | ✅ |
| Trial 30 days | Task 6 (trialEndsAt) | ✅ |
| Managed DB provisioned at signup | Task 6 (provisionDatabase) | ✅ |
| JWT + refresh token on signup | Task 6 | ✅ |

**Deferred to Plan 2B:**
- CRUD contribuyentes endpoints (add/update/delete RFC within despacho)
- FIEL/CSD assignment to contribuyente (not tenant)
- CFDI emission with contribuyente_id FK
- Metrics tables (metricas_mensuales etc.)
- Magic link auth flow

**Deferred to Plan 2C:**
- Frontend signup page
- Dashboard adapted for despacho
- Contribuyente selector UI
- Onboarding wizard

### Placeholder scan

- No "TBD", "TODO", "implement later" found.
- All code blocks contain complete, copy-paste-ready code.
- Types referenced (DespachoSignupRequest, JWTPayload, Role, etc.) are all defined in tasks.

### Type consistency

- `DespachoRole` = `'owner' | 'supervisor' | 'auxiliar' | 'cliente'` — consistent with spec §5.
- `VerticalProfile` = `'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA'` — matches Prisma enum.
- `DbMode` = `'BYO' | 'MANAGED'` — matches Prisma enum.
- `signupDespacho()` accepts `DespachoSignupRequest` and returns `LoginResponse`-compatible shape.
- SQL migrations use `gen_random_uuid()`, `timestamptz`, `varchar` — consistent with existing migrations.
- `tenant_migrations` table uses `(scope, version)` PK — matches spec §12.
