Update: nueva version Horux Despachos

This commit is contained in:
consultoria-as
2026-04-27 22:09:36 -06:00
commit 6b36db1403
614 changed files with 125926 additions and 0 deletions

View File

@@ -0,0 +1,772 @@
# 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.