Files
HoruxDespachosNuevo/docs/superpowers/plans/2026-04-17-plan2a-schema-auth.md

26 KiB

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 { ... }:

enum VerticalProfile {
  CONTABLE
  JURIDICO
  ARQUITECTURA
}

enum DbMode {
  BYO
  MANAGED
}

In the model Tenant { ... }, add AFTER the telefono field (before the relations block):

  // === 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:

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:

cd apps/api && npx prisma migrate dev --name despacho_fields --create-only
  • Step 3: Generate Prisma client

Run:

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
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:

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:

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
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:

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:

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
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:

-- 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:

-- 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
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:

-- 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:

-- 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:

-- 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
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:

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:

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:

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:

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
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:

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:

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:

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

Expected: all 4 fields present.

  • Step 4: Verify commit history

Run:

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):

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