fix(clientes): crear tenant como despacho desde admin global
Antes, createTenant() solo seteaba nombre, rfc, plan y databaseName. Ahora registra tenants completos como despachos: - dbMode: 'MANAGED' - verticalProfile: CONTABLE | JURIDICO | ARQUITECTURA - trialEndsAt: +30 días para plan trial - codigoPostal: opcional (se llena automáticamente de la CSF al subir FIEL) Frontend: - Selector de Tipo de Despacho en /clientes - C.P. omitido del formulario (viene de CSF -> sincronizarDatosFiscales) - Tipos Tenant y CreateTenantData actualizados Backend: - getAllTenants y getTenantById retornan verticalProfile y codigoPostal Refs: docs/sessions/2026-05-04-fix-clientes-crea-despacho.md
This commit is contained in:
@@ -52,7 +52,7 @@ export async function createTenant(req: Request, res: Response, next: NextFuncti
|
|||||||
try {
|
try {
|
||||||
await requireGlobalAdmin(req);
|
await requireGlobalAdmin(req);
|
||||||
|
|
||||||
const { nombre, rfc, plan, adminEmail, adminNombre, amount, firstPaymentDueAt } = req.body;
|
const { nombre, rfc, plan, adminEmail, adminNombre, amount, firstPaymentDueAt, verticalProfile, codigoPostal } = req.body;
|
||||||
|
|
||||||
if (!nombre || !rfc || !adminEmail || !adminNombre) {
|
if (!nombre || !rfc || !adminEmail || !adminNombre) {
|
||||||
throw new AppError(400, 'Nombre, RFC, adminEmail y adminNombre son requeridos');
|
throw new AppError(400, 'Nombre, RFC, adminEmail y adminNombre son requeridos');
|
||||||
@@ -66,6 +66,8 @@ export async function createTenant(req: Request, res: Response, next: NextFuncti
|
|||||||
adminNombre,
|
adminNombre,
|
||||||
amount: amount || 0,
|
amount: amount || 0,
|
||||||
firstPaymentDueAt: firstPaymentDueAt || null,
|
firstPaymentDueAt: firstPaymentDueAt || null,
|
||||||
|
verticalProfile: verticalProfile || 'CONTABLE',
|
||||||
|
codigoPostal,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json(result);
|
res.status(201).json(result);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export async function getAllTenants() {
|
|||||||
plan: true,
|
plan: true,
|
||||||
databaseName: true,
|
databaseName: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
verticalProfile: true,
|
||||||
|
codigoPostal: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: { memberships: { where: { active: true } } as any }
|
select: { memberships: { where: { active: true } } as any }
|
||||||
},
|
},
|
||||||
@@ -43,6 +45,8 @@ export async function getTenantById(id: string) {
|
|||||||
plan: true,
|
plan: true,
|
||||||
databaseName: true,
|
databaseName: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
verticalProfile: true,
|
||||||
|
codigoPostal: true,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -57,6 +61,8 @@ export async function createTenant(data: {
|
|||||||
/** Solo plan custom: primera fecha de pago (deadline para que el cliente
|
/** Solo plan custom: primera fecha de pago (deadline para que el cliente
|
||||||
* complete su primer cobro). Se persiste como Subscription.currentPeriodEnd. */
|
* complete su primer cobro). Se persiste como Subscription.currentPeriodEnd. */
|
||||||
firstPaymentDueAt?: string | null;
|
firstPaymentDueAt?: string | null;
|
||||||
|
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
|
||||||
|
codigoPostal?: string;
|
||||||
}) {
|
}) {
|
||||||
const plan = data.plan || 'trial';
|
const plan = data.plan || 'trial';
|
||||||
|
|
||||||
@@ -70,12 +76,18 @@ export async function createTenant(data: {
|
|||||||
}).catch(err => console.error('[METABASE] Register failed:', err));
|
}).catch(err => console.error('[METABASE] Register failed:', err));
|
||||||
|
|
||||||
// 2. Create tenant record
|
// 2. Create tenant record
|
||||||
|
const isTrial = plan === 'trial';
|
||||||
|
|
||||||
const tenant = await prisma.tenant.create({
|
const tenant = await prisma.tenant.create({
|
||||||
data: {
|
data: {
|
||||||
nombre: data.nombre,
|
nombre: data.nombre,
|
||||||
rfc: data.rfc.toUpperCase(),
|
rfc: data.rfc.toUpperCase(),
|
||||||
plan,
|
plan,
|
||||||
databaseName,
|
databaseName,
|
||||||
|
dbMode: 'MANAGED',
|
||||||
|
verticalProfile: data.verticalProfile || 'CONTABLE',
|
||||||
|
codigoPostal: data.codigoPostal || undefined,
|
||||||
|
trialEndsAt: isTrial ? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) : undefined,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export default function ClientesPage() {
|
|||||||
adminNombre: string;
|
adminNombre: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
firstPaymentDueAt: string;
|
firstPaymentDueAt: string;
|
||||||
|
verticalProfile: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
|
||||||
}>({
|
}>({
|
||||||
nombre: '',
|
nombre: '',
|
||||||
rfc: '',
|
rfc: '',
|
||||||
@@ -92,6 +93,7 @@ export default function ClientesPage() {
|
|||||||
adminNombre: '',
|
adminNombre: '',
|
||||||
amount: 0,
|
amount: 0,
|
||||||
firstPaymentDueAt: '',
|
firstPaymentDueAt: '',
|
||||||
|
verticalProfile: 'CONTABLE',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only global admin can access this page
|
// Only global admin can access this page
|
||||||
@@ -122,7 +124,7 @@ export default function ClientesPage() {
|
|||||||
} else {
|
} else {
|
||||||
await createTenant.mutateAsync(formData);
|
await createTenant.mutateAsync(formData);
|
||||||
}
|
}
|
||||||
setFormData({ nombre: '', rfc: '', plan: 'trial', adminEmail: '', adminNombre: '', amount: 0, firstPaymentDueAt: '' });
|
setFormData({ nombre: '', rfc: '', plan: 'trial', adminEmail: '', adminNombre: '', amount: 0, firstPaymentDueAt: '', verticalProfile: 'CONTABLE' });
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
@@ -142,6 +144,7 @@ export default function ClientesPage() {
|
|||||||
firstPaymentDueAt: sub?.currentPeriodEnd
|
firstPaymentDueAt: sub?.currentPeriodEnd
|
||||||
? new Date(sub.currentPeriodEnd).toISOString().slice(0, 10)
|
? new Date(sub.currentPeriodEnd).toISOString().slice(0, 10)
|
||||||
: '',
|
: '',
|
||||||
|
verticalProfile: tenant.verticalProfile || 'CONTABLE',
|
||||||
});
|
});
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
@@ -159,7 +162,7 @@ export default function ClientesPage() {
|
|||||||
const handleCancelForm = () => {
|
const handleCancelForm = () => {
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setEditingTenant(null);
|
setEditingTenant(null);
|
||||||
setFormData({ nombre: '', rfc: '', plan: 'trial', adminEmail: '', adminNombre: '', amount: 0, firstPaymentDueAt: '' });
|
setFormData({ nombre: '', rfc: '', plan: 'trial', adminEmail: '', adminNombre: '', amount: 0, firstPaymentDueAt: '', verticalProfile: 'CONTABLE' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewClient = (tenantId: string, tenantName: string) => {
|
const handleViewClient = (tenantId: string, tenantName: string) => {
|
||||||
@@ -477,6 +480,25 @@ export default function ClientesPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="verticalProfile">Tipo de Despacho</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.verticalProfile}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFormData({ ...formData, verticalProfile: value as 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="CONTABLE">Contable</SelectItem>
|
||||||
|
<SelectItem value="JURIDICO">Jurídico</SelectItem>
|
||||||
|
<SelectItem value="ARQUITECTURA">Arquitectura</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Campos de admin — solo al crear */}
|
{/* Campos de admin — solo al crear */}
|
||||||
{!editingTenant && (
|
{!editingTenant && (
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface Tenant {
|
|||||||
plan: string;
|
plan: string;
|
||||||
databaseName: string;
|
databaseName: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA' | null;
|
||||||
|
codigoPostal?: string | null;
|
||||||
_count?: {
|
_count?: {
|
||||||
/** Memberships activos (matches el `_count.memberships` que retorna `getAllTenants`). */
|
/** Memberships activos (matches el `_count.memberships` que retorna `getAllTenants`). */
|
||||||
memberships: number;
|
memberships: number;
|
||||||
@@ -32,6 +34,10 @@ export interface CreateTenantData {
|
|||||||
amount?: number;
|
amount?: number;
|
||||||
/** Solo plan custom: deadline para primer pago (formato ISO YYYY-MM-DD). */
|
/** Solo plan custom: deadline para primer pago (formato ISO YYYY-MM-DD). */
|
||||||
firstPaymentDueAt?: string | null;
|
firstPaymentDueAt?: string | null;
|
||||||
|
/** Tipo de despacho (CONTABLE, JURIDICO, ARQUITECTURA). Default: CONTABLE */
|
||||||
|
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
|
||||||
|
/** Código postal del domicilio fiscal (5 dígitos) */
|
||||||
|
codigoPostal?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTenants(): Promise<Tenant[]> {
|
export async function getTenants(): Promise<Tenant[]> {
|
||||||
|
|||||||
130
docs/sessions/2026-05-04-fix-clientes-crea-despacho.md
Normal file
130
docs/sessions/2026-05-04-fix-clientes-crea-despacho.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# Sesión: Fix — Crear cliente desde `/clientes` registra como despacho
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
**Bug:** El admin global creaba tenants "legacy" (sin `dbMode`, `verticalProfile`, `trialEndsAt`) desde `/clientes`. Ahora se registran como despachos completos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Problema
|
||||||
|
|
||||||
|
### Síntoma
|
||||||
|
Cuando el administrador global creaba un nuevo cliente desde `/clientes`, el tenant se creaba sin los campos de despacho:
|
||||||
|
- `dbMode` = `null` (debería ser `MANAGED`)
|
||||||
|
- `verticalProfile` = `null` (debería ser `CONTABLE`/`JURIDICO`/`ARQUITECTURA`)
|
||||||
|
- `trialEndsAt` = `null` (debería ser +30 días para plan trial)
|
||||||
|
- `codigoPostal` no se seteaba
|
||||||
|
|
||||||
|
Esto causaba que el nuevo cliente no funcionara correctamente como despacho (no aparecía en el selector de despachos, no tenía trial, etc.).
|
||||||
|
|
||||||
|
### Causa raíz
|
||||||
|
El flujo de **registro público** (`/register-despacho` → `signupDespacho()`) sí seteaba estos campos correctamente.
|
||||||
|
Pero el flujo de **creación por admin** (`/clientes` → `createTenant()`) no lo hacía — solo creaba el tenant con `nombre`, `rfc`, `plan`, `databaseName`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Cambios realizados
|
||||||
|
|
||||||
|
### 2.1 Backend — `tenants.service.ts`
|
||||||
|
|
||||||
|
**Firma ampliada:**
|
||||||
|
```ts
|
||||||
|
export async function createTenant(data: {
|
||||||
|
// ... campos existentes ...
|
||||||
|
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
|
||||||
|
codigoPostal?: string;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Creación del tenant ahora setea despacho fields:**
|
||||||
|
```ts
|
||||||
|
const tenant = await prisma.tenant.create({
|
||||||
|
data: {
|
||||||
|
nombre: data.nombre,
|
||||||
|
rfc: data.rfc.toUpperCase(),
|
||||||
|
plan,
|
||||||
|
databaseName,
|
||||||
|
dbMode: 'MANAGED',
|
||||||
|
verticalProfile: data.verticalProfile || 'CONTABLE',
|
||||||
|
codigoPostal: data.codigoPostal || undefined,
|
||||||
|
trialEndsAt: isTrial ? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) : undefined,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Queries `getAllTenants` y `getTenantById` ahora retornan:**
|
||||||
|
- `verticalProfile`
|
||||||
|
- `codigoPostal`
|
||||||
|
|
||||||
|
### 2.2 Backend — `tenants.controller.ts`
|
||||||
|
|
||||||
|
El endpoint `POST /tenants` ahora acepta y pasa:
|
||||||
|
- `verticalProfile` (default: `'CONTABLE'`)
|
||||||
|
- `codigoPostal` (opcional)
|
||||||
|
|
||||||
|
### 2.3 Frontend — `clientes/page.tsx`
|
||||||
|
|
||||||
|
**Nuevo campo en el formulario:** `Tipo de Despacho`
|
||||||
|
- Select con opciones: Contable, Jurídico, Arquitectura
|
||||||
|
- Default: Contable
|
||||||
|
- Se persiste al crear y se carga al editar
|
||||||
|
|
||||||
|
**C.P. deliberadamente omitido:**
|
||||||
|
El campo `codigoPostal` **no** se incluyó en el formulario. Se obtiene automáticamente de la CSF cuando el dueño del despacho sube su FIEL por primera vez (`fiel.service.ts` → `consultarConstancia()` → `sincronizarDatosFiscales()`).
|
||||||
|
|
||||||
|
### 2.4 Frontend — `lib/api/tenants.ts`
|
||||||
|
|
||||||
|
Tipos actualizados:
|
||||||
|
```ts
|
||||||
|
export interface Tenant {
|
||||||
|
// ... campos existentes ...
|
||||||
|
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA' | null;
|
||||||
|
codigoPostal?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTenantData {
|
||||||
|
// ... campos existentes ...
|
||||||
|
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
|
||||||
|
codigoPostal?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Flujo resultante
|
||||||
|
|
||||||
|
```
|
||||||
|
Admin global crea cliente en /clientes
|
||||||
|
↓
|
||||||
|
Tenant creado con:
|
||||||
|
dbMode = 'MANAGED'
|
||||||
|
verticalProfile = (seleccionado por admin, default CONTABLE)
|
||||||
|
trialEndsAt = +30 días (si plan = trial)
|
||||||
|
codigoPostal = undefined (se llenará después)
|
||||||
|
↓
|
||||||
|
Dueño del despacho entra a la plataforma
|
||||||
|
↓
|
||||||
|
Sube FIEL del despacho (primer contribuyente)
|
||||||
|
↓
|
||||||
|
Sistema extrae CSF automáticamente
|
||||||
|
↓
|
||||||
|
Tenant actualizado con codigoPostal, calle, estado, regímenes...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Archivos modificados
|
||||||
|
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---------|--------|
|
||||||
|
| `apps/api/src/services/tenants.service.ts` | `createTenant` setea `dbMode`, `verticalProfile`, `trialEndsAt`, `codigoPostal`; queries retornan nuevos campos |
|
||||||
|
| `apps/api/src/controllers/tenants.controller.ts` | Acepta `verticalProfile` y `codigoPostal` del body |
|
||||||
|
| `apps/web/app/(dashboard)/clientes/page.tsx` | Formulario con selector de `verticalProfile`; C.P. omitido (viene de CSF) |
|
||||||
|
| `apps/web/lib/api/tenants.ts` | Tipos `Tenant` y `CreateTenantData` ampliados |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Notas
|
||||||
|
|
||||||
|
- El campo `codigoPostal` del backend es **opcional** y se dejó en la firma por si en el futuro se quiere pasar manualmente (ej. onboarding asistido).
|
||||||
|
- No se requirió migración de base de datos — `codigoPostal` ya existía como columna `String?` en `Tenant`.
|
||||||
|
- `trialEndsAt` se calcula como `Date.now() + 30 días` para plan `trial`, igual que en `signupDespacho()`.
|
||||||
Reference in New Issue
Block a user